grove/
config.rs

1//! Configuration management for grove.
2//!
3//! Grove uses a layered configuration system:
4//!
5//! 1. **Global config** (`~/.config/grove/config.toml`) — The project registry.
6//!    Maps project names to repository paths, with optional database and hooks config.
7//!
8//! 2. **Repo config** (`.grove/config.toml` in the repository root) — Per-repo
9//!    defaults for database URLs, hooks, and environment variables. Committed to
10//!    the repo so all contributors share the same base config.
11//!
12//! 3. **Environment variables** (`~/.config/grove/envs/`) — Per-project and
13//!    per-worktree env var overrides stored outside the repo. Worktree values
14//!    override project values, which override repo defaults.
15
16use std::collections::BTreeMap;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Error, Result};
23
24#[derive(Debug)]
25pub struct ProjectRef {
26    pub project: String,
27    pub worktree: Option<String>,
28}
29
30impl ProjectRef {
31    pub fn parse(input: &str) -> Result<Self> {
32        let parts: Vec<&str> = input.split('/').collect();
33        match parts.as_slice() {
34            [project] if !project.is_empty() => Ok(Self {
35                project: (*project).to_string(),
36                worktree: None,
37            }),
38            [project, worktree] if !project.is_empty() && !worktree.is_empty() => Ok(Self {
39                project: (*project).to_string(),
40                worktree: Some((*worktree).to_string()),
41            }),
42            _ => Err(Error::InvalidProjectRef(input.to_string())),
43        }
44    }
45}
46
47#[derive(Debug, Default, Serialize, Deserialize)]
48pub struct Config {
49    #[serde(default)]
50    pub projects: BTreeMap<String, Project>,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct DatabaseConfig {
55    pub url_template: String,
56    #[serde(default)]
57    pub setup_command: Option<String>,
58    #[serde(default)]
59    pub env_var: Option<String>,
60}
61
62impl DatabaseConfig {
63    #[allow(clippy::unused_self)]
64    pub fn db_name(&self, project: &str, worktree: &str) -> String {
65        let raw = format!("{project}_{worktree}");
66        raw.chars()
67            .map(|c| {
68                if c.is_ascii_alphanumeric() || c == '_' {
69                    c
70                } else {
71                    '_'
72                }
73            })
74            .collect::<String>()
75            .to_lowercase()
76    }
77
78    pub fn database_url(&self, project: &str, worktree: &str) -> String {
79        let db_name = self.db_name(project, worktree);
80        self.url_template.replace("{{db_name}}", &db_name)
81    }
82
83    pub fn env_var_name(&self) -> &str {
84        self.env_var.as_deref().unwrap_or("DATABASE_URL")
85    }
86}
87
88#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
89pub struct HooksConfig {
90    #[serde(default)]
91    pub post_create: Vec<String>,
92}
93
94#[derive(Debug, Default, Clone, Serialize, Deserialize)]
95pub struct RepoConfig {
96    pub name: Option<String>,
97    #[serde(default)]
98    pub database: Option<DatabaseConfig>,
99    #[serde(default)]
100    pub hooks: Option<HooksConfig>,
101    #[serde(default)]
102    pub env: Option<BTreeMap<String, String>>,
103}
104
105impl RepoConfig {
106    pub fn load_from_dir(dir: &Path) -> Result<Option<Self>> {
107        let config_path = dir.join(".grove").join("config.toml");
108        if !config_path.exists() {
109            return Ok(None);
110        }
111        let content = fs::read_to_string(&config_path)?;
112        let config: RepoConfig = toml::from_str(&content)?;
113        Ok(Some(config))
114    }
115
116    /// Walk up from `path` looking for `.grove/config.toml`.
117    /// Returns `(config, repo_root)` where the second element is the main repo root.
118    /// When `.git` is a file (git worktree) or `.jj/repo` is a symlink (jj workspace),
119    /// resolves back to the main repo root.
120    pub fn discover(path: &Path) -> Result<Option<(Self, PathBuf)>> {
121        let mut current = path.canonicalize()?;
122        loop {
123            if let Some(config) = Self::load_from_dir(&current)? {
124                let dot_git = current.join(".git");
125                if dot_git.is_file() {
126                    // Git worktree — resolve .git file to find main repo
127                    if let Some(main_repo) = resolve_main_repo_from_dot_git_file(&dot_git)? {
128                        return Ok(Some((config, main_repo)));
129                    }
130                    // Resolution failed — fall through to continue walking up
131                } else {
132                    // Check if this is a jj workspace (not the main repo)
133                    let dot_jj_repo = current.join(".jj").join("repo");
134                    if dot_jj_repo.is_symlink() {
135                        if let Some(main_repo) = resolve_main_repo_from_jj_workspace(&dot_jj_repo) {
136                            return Ok(Some((config, main_repo)));
137                        }
138                    }
139                    return Ok(Some((config, current)));
140                }
141            }
142            if !current.pop() {
143                return Ok(None);
144            }
145        }
146    }
147
148    pub fn effective_name(&self, repo_root: &Path) -> String {
149        self.name.clone().unwrap_or_else(|| {
150            repo_root.file_name().map_or_else(
151                || "unknown".to_string(),
152                |n| n.to_string_lossy().to_string(),
153            )
154        })
155    }
156}
157
158/// Resolve a git worktree's `.git` file to find the main repository root.
159///
160/// In a git worktree, `.git` is a file containing `gitdir: <path>`, where
161/// `<path>` points to `<main_repo>/.git/worktrees/<name>`. Walks up the gitdir
162/// path to find the `.git` component and returns its parent as the main repo root.
163fn resolve_main_repo_from_dot_git_file(dot_git_file: &Path) -> Result<Option<PathBuf>> {
164    let content = fs::read_to_string(dot_git_file)?;
165    let Some(gitdir_str) = content.trim().strip_prefix("gitdir: ") else {
166        return Ok(None);
167    };
168
169    let base_dir = dot_git_file
170        .parent()
171        .expect("dot_git_file always has a parent directory");
172    let gitdir_path = if Path::new(gitdir_str).is_absolute() {
173        PathBuf::from(gitdir_str)
174    } else {
175        base_dir.join(gitdir_str)
176    };
177
178    let Ok(canonical) = gitdir_path.canonicalize() else {
179        return Ok(None);
180    };
181
182    for ancestor in canonical.ancestors() {
183        if ancestor.file_name() == Some(std::ffi::OsStr::new(".git")) {
184            return Ok(ancestor.parent().map(Path::to_path_buf));
185        }
186    }
187
188    Ok(None)
189}
190
191/// Resolve a jj workspace's `.jj/repo` symlink to find the main repository root.
192///
193/// In a jj workspace, `.jj/repo` is a symlink pointing to `<main_repo>/.jj/repo`.
194fn resolve_main_repo_from_jj_workspace(dot_jj_repo: &Path) -> Option<PathBuf> {
195    let canonical = dot_jj_repo.canonicalize().ok()?;
196    // canonical points to <main_repo>/.jj/repo
197    let jj_dir = canonical.parent()?;
198    if jj_dir.file_name() == Some(std::ffi::OsStr::new(".jj")) {
199        jj_dir.parent().map(Path::to_path_buf)
200    } else {
201        None
202    }
203}
204
205pub fn merge_project(
206    repo_config: Option<&RepoConfig>,
207    user_project: Option<&Project>,
208    path: PathBuf,
209) -> Project {
210    let repo_db = repo_config.and_then(|rc| rc.database.clone());
211    let repo_hooks = repo_config.and_then(|rc| rc.hooks.clone());
212
213    Project {
214        path,
215        worktree_base: user_project.and_then(|p| p.worktree_base.clone()),
216        database: user_project.and_then(|p| p.database.clone()).or(repo_db),
217        hooks: user_project.and_then(|p| p.hooks.clone()).or(repo_hooks),
218    }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct Project {
223    pub path: PathBuf,
224    #[serde(default)]
225    pub worktree_base: Option<PathBuf>,
226    #[serde(default)]
227    pub database: Option<DatabaseConfig>,
228    #[serde(default)]
229    pub hooks: Option<HooksConfig>,
230}
231
232impl Project {
233    /// Get the worktree base directory for this project.
234    /// Returns the configured `worktree_base`, or defaults to `<project_path>/.worktrees`.
235    pub fn worktree_base(&self) -> PathBuf {
236        self.worktree_base
237            .clone()
238            .unwrap_or_else(|| self.path.join(".worktrees"))
239    }
240}
241
242#[derive(Debug, Default, Serialize, Deserialize)]
243pub struct EnvVars {
244    #[serde(flatten)]
245    pub vars: BTreeMap<String, String>,
246}
247
248fn config_dir() -> Result<PathBuf> {
249    // Allow override via GROVE_CONFIG_DIR for testing
250    if let Ok(dir) = std::env::var("GROVE_CONFIG_DIR") {
251        return Ok(PathBuf::from(dir));
252    }
253    dirs::config_dir()
254        .map(|p| p.join("grove"))
255        .ok_or(Error::NoConfigDir)
256}
257
258fn config_path() -> Result<PathBuf> {
259    Ok(config_dir()?.join("config.toml"))
260}
261
262fn envs_dir() -> Result<PathBuf> {
263    Ok(config_dir()?.join("envs"))
264}
265
266pub(crate) fn env_path(project: &str) -> Result<PathBuf> {
267    Ok(envs_dir()?.join(format!("{project}.toml")))
268}
269
270pub(crate) fn worktree_env_path(project: &str, worktree: &str) -> Result<PathBuf> {
271    Ok(envs_dir()?.join(project).join(format!("{worktree}.toml")))
272}
273
274impl Config {
275    pub fn load() -> Result<Self> {
276        let path = config_path()?;
277        if !path.exists() {
278            return Ok(Self::default());
279        }
280        let content = fs::read_to_string(&path)?;
281        let config: Config = toml::from_str(&content)?;
282        Ok(config)
283    }
284
285    pub fn save(&self) -> Result<()> {
286        let path = config_path()?;
287        if let Some(parent) = path.parent() {
288            fs::create_dir_all(parent)?;
289        }
290        let content = toml::to_string_pretty(self)?;
291        fs::write(&path, content)?;
292        Ok(())
293    }
294
295    pub fn add_project(&mut self, name: String, path: PathBuf) -> Result<()> {
296        if self.projects.contains_key(&name) {
297            return Err(Error::ProjectExists(name));
298        }
299
300        // Validate path exists and is a git/jj repo
301        if !path.exists() {
302            return Err(Error::PathNotFound(path));
303        }
304        if !path.join(".git").exists() && !path.join(".jj").exists() {
305            return Err(Error::NotVcsRepo(path));
306        }
307
308        let canonical = path.canonicalize()?;
309        self.projects.insert(
310            name,
311            Project {
312                path: canonical,
313                worktree_base: None,
314                database: None,
315                hooks: None,
316            },
317        );
318        Ok(())
319    }
320
321    pub fn remove_project(&mut self, name: &str) -> Result<()> {
322        if self.projects.remove(name).is_none() {
323            return Err(Error::ProjectNotFound(name.to_string()));
324        }
325        Ok(())
326    }
327
328    /// Find which project a path belongs to.
329    ///
330    /// Checks in order:
331    /// 1. Path is a subdirectory of a project's worktree base (more specific match)
332    /// 2. Path is a subdirectory of a project's main repo
333    pub fn find_project_for_path(&self, path: &Path) -> Result<Option<ProjectRef>> {
334        let canonical = path.canonicalize()?;
335
336        // First: check if path is in any project's worktree base (more specific match)
337        for (name, project) in &self.projects {
338            let wt_base = project.worktree_base();
339            if let Ok(canonical_base) = wt_base.canonicalize() {
340                if canonical.starts_with(&canonical_base) {
341                    let rel = canonical.strip_prefix(&canonical_base).unwrap();
342                    if let Some(wt_dir) = rel.components().next() {
343                        let wt_name = wt_dir.as_os_str().to_string_lossy().to_string();
344                        return Ok(Some(ProjectRef {
345                            project: name.clone(),
346                            worktree: Some(wt_name),
347                        }));
348                    }
349                }
350            }
351        }
352
353        // Second: check if path is in a project's main repo
354        for (name, project) in &self.projects {
355            if canonical.starts_with(&project.path) {
356                return Ok(Some(ProjectRef {
357                    project: name.clone(),
358                    worktree: None,
359                }));
360            }
361        }
362
363        Ok(None)
364    }
365}
366
367impl EnvVars {
368    pub fn load(project: &str) -> Result<Self> {
369        let path = env_path(project)?;
370        if !path.exists() {
371            return Ok(Self::default());
372        }
373        let content = fs::read_to_string(&path)?;
374        let vars: EnvVars = toml::from_str(&content)?;
375        Ok(vars)
376    }
377
378    pub fn save(&self, project: &str) -> Result<()> {
379        let path = env_path(project)?;
380        if let Some(parent) = path.parent() {
381            fs::create_dir_all(parent)?;
382        }
383        let content = toml::to_string_pretty(self)?;
384        fs::write(&path, content)?;
385        Ok(())
386    }
387
388    pub fn load_worktree(project: &str, worktree: &str) -> Result<Self> {
389        let path = worktree_env_path(project, worktree)?;
390        if !path.exists() {
391            return Ok(Self::default());
392        }
393        let content = fs::read_to_string(&path)?;
394        let vars: Self = toml::from_str(&content)?;
395        Ok(vars)
396    }
397
398    pub fn save_worktree(&self, project: &str, worktree: &str) -> Result<()> {
399        let path = worktree_env_path(project, worktree)?;
400        if let Some(parent) = path.parent() {
401            fs::create_dir_all(parent)?;
402        }
403        let content = toml::to_string_pretty(self)?;
404        fs::write(&path, content)?;
405        Ok(())
406    }
407
408    pub fn set(&mut self, key: String, value: String) {
409        self.vars.insert(key, value);
410    }
411
412    pub fn remove(&mut self, key: &str) -> bool {
413        self.vars.remove(key).is_some()
414    }
415}
416
417#[derive(Debug, Clone, Copy)]
418pub enum EnvSource {
419    Repo,
420    Project,
421    Worktree,
422}
423
424#[derive(Debug)]
425pub struct MergedEnvVar {
426    pub key: String,
427    pub value: String,
428    pub source: EnvSource,
429}
430
431pub fn load_merged_env(
432    project_name: &str,
433    worktree: Option<&str>,
434    repo_env: &BTreeMap<String, String>,
435) -> Result<Vec<MergedEnvVar>> {
436    // Layer 1: repo env vars (base)
437    let mut merged: BTreeMap<String, (String, EnvSource)> = repo_env
438        .iter()
439        .map(|(k, v)| (k.clone(), (v.clone(), EnvSource::Repo)))
440        .collect();
441
442    // Layer 2: user project-level env vars
443    let user_env = EnvVars::load(project_name)?;
444    for (k, v) in user_env.vars {
445        merged.insert(k, (v, EnvSource::Project));
446    }
447
448    // Layer 3: user worktree-level env vars
449    if let Some(wt) = worktree {
450        let wt_env = EnvVars::load_worktree(project_name, wt)?;
451        for (k, v) in wt_env.vars {
452            merged.insert(k, (v, EnvSource::Worktree));
453        }
454    }
455
456    Ok(merged
457        .into_iter()
458        .map(|(key, (value, source))| MergedEnvVar { key, value, source })
459        .collect())
460}
461
462/// Resolve a project by explicit name or auto-detection from cwd.
463/// Returns `(name, project, repo_env_vars)`.
464pub fn resolve_project(
465    config: &Config,
466    explicit_name: Option<&str>,
467) -> Result<(String, Project, BTreeMap<String, String>)> {
468    if let Some(name) = explicit_name {
469        if let Some(user_proj) = config.projects.get(name) {
470            let repo_config = RepoConfig::load_from_dir(&user_proj.path)?;
471            let merged = merge_project(
472                repo_config.as_ref(),
473                Some(user_proj),
474                user_proj.path.clone(),
475            );
476            let repo_env = repo_config.and_then(|rc| rc.env).unwrap_or_default();
477            return Ok((name.to_string(), merged, repo_env));
478        }
479
480        // Not registered — try auto-detection from cwd and match by name
481        let cwd = std::env::current_dir()?;
482        if let Some((repo_config, repo_root)) = RepoConfig::discover(&cwd)? {
483            let detected_name = repo_config.effective_name(&repo_root);
484            if detected_name == name {
485                let user_proj = config.projects.get(&detected_name);
486                let path = user_proj.map_or(repo_root, |p| p.path.clone());
487                let merged = merge_project(Some(&repo_config), user_proj, path);
488                let repo_env = repo_config.env.unwrap_or_default();
489                return Ok((name.to_string(), merged, repo_env));
490            }
491        }
492
493        return Err(Error::ProjectNotFound(name.to_string()));
494    }
495
496    let cwd = std::env::current_dir()?;
497
498    if let Some(project_ref) = config.find_project_for_path(&cwd)? {
499        let user_proj = config.projects.get(&project_ref.project).unwrap();
500        let repo_config = RepoConfig::load_from_dir(&user_proj.path)?;
501        let merged = merge_project(
502            repo_config.as_ref(),
503            Some(user_proj),
504            user_proj.path.clone(),
505        );
506        let repo_env = repo_config.and_then(|rc| rc.env).unwrap_or_default();
507        return Ok((project_ref.project, merged, repo_env));
508    }
509
510    if let Some((repo_config, repo_root)) = RepoConfig::discover(&cwd)? {
511        let name = repo_config.effective_name(&repo_root);
512        let user_proj = config.projects.get(&name);
513        let path = user_proj.map(|p| p.path.clone()).unwrap_or(repo_root);
514        let merged = merge_project(Some(&repo_config), user_proj, path);
515        let repo_env = repo_config.env.unwrap_or_default();
516        return Ok((name, merged, repo_env));
517    }
518
519    Err(Error::NoProjectDetected)
520}
521
522/// Resolved project info for a filesystem path.
523pub type ResolvedProjectForPath = (String, Project, Option<String>, BTreeMap<String, String>);
524
525/// Resolve a project for a filesystem path (used by env export).
526/// Returns `(name, project, worktree_name, repo_env_vars)`.
527pub fn resolve_project_for_path(
528    config: &Config,
529    path: &Path,
530) -> Result<Option<ResolvedProjectForPath>> {
531    if let Some(project_ref) = config.find_project_for_path(path)? {
532        let user_proj = config.projects.get(&project_ref.project).unwrap();
533        let repo_config = RepoConfig::load_from_dir(&user_proj.path)?;
534        let merged = merge_project(
535            repo_config.as_ref(),
536            Some(user_proj),
537            user_proj.path.clone(),
538        );
539        let repo_env = repo_config.and_then(|rc| rc.env).unwrap_or_default();
540        return Ok(Some((
541            project_ref.project,
542            merged,
543            project_ref.worktree,
544            repo_env,
545        )));
546    }
547
548    if let Some((repo_config, repo_root)) = RepoConfig::discover(path)? {
549        let name = repo_config.effective_name(&repo_root);
550        let user_proj = config.projects.get(&name);
551        let proj_path = user_proj.map_or_else(|| repo_root.clone(), |p| p.path.clone());
552        let merged = merge_project(Some(&repo_config), user_proj, proj_path);
553
554        let canonical = path.canonicalize()?;
555        let worktree = {
556            let wt_base = merged.worktree_base();
557            if let Ok(canonical_base) = wt_base.canonicalize() {
558                if canonical.starts_with(&canonical_base) {
559                    let rel = canonical.strip_prefix(&canonical_base).unwrap();
560                    rel.components()
561                        .next()
562                        .map(|c| c.as_os_str().to_string_lossy().to_string())
563                } else {
564                    None
565                }
566            } else {
567                None
568            }
569        };
570
571        let repo_env = repo_config.env.unwrap_or_default();
572        return Ok(Some((name, merged, worktree, repo_env)));
573    }
574
575    Ok(None)
576}
577
578pub fn export_merged_env(vars: &[MergedEnvVar]) -> String {
579    vars.iter()
580        .map(|var| format!("export {}={}", var.key, shell_escape(&var.value)))
581        .collect::<Vec<_>>()
582        .join("\n")
583}
584
585fn shell_escape(s: &str) -> String {
586    // Always quote for consistency and safety
587    format!("'{}'", s.replace('\'', "'\"'\"'"))
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn test_shell_escape_simple() {
596        assert_eq!(shell_escape("hello"), "'hello'");
597        assert_eq!(shell_escape("/path/to/thing"), "'/path/to/thing'");
598    }
599
600    #[test]
601    fn test_shell_escape_special() {
602        assert_eq!(shell_escape("hello world"), "'hello world'");
603        assert_eq!(shell_escape("it's"), "'it'\"'\"'s'");
604    }
605
606    #[test]
607    fn test_project_ref_parse_project_only() {
608        let pr = ProjectRef::parse("mull").unwrap();
609        assert_eq!(pr.project, "mull");
610        assert!(pr.worktree.is_none());
611    }
612
613    #[test]
614    fn test_project_ref_parse_with_worktree() {
615        let pr = ProjectRef::parse("mull/discord").unwrap();
616        assert_eq!(pr.project, "mull");
617        assert_eq!(pr.worktree.as_deref(), Some("discord"));
618    }
619
620    #[test]
621    fn test_project_ref_parse_too_many_parts() {
622        assert!(ProjectRef::parse("mull/discord/extra").is_err());
623    }
624
625    #[test]
626    fn test_project_ref_parse_leading_slash() {
627        assert!(ProjectRef::parse("/discord").is_err());
628    }
629
630    #[test]
631    fn test_project_ref_parse_trailing_slash() {
632        assert!(ProjectRef::parse("mull/").is_err());
633    }
634
635    #[test]
636    fn test_project_ref_parse_empty() {
637        assert!(ProjectRef::parse("").is_err());
638    }
639
640    #[test]
641    fn test_db_name_basic() {
642        let cfg = DatabaseConfig {
643            url_template: String::new(),
644            setup_command: None,
645            env_var: None,
646        };
647        assert_eq!(cfg.db_name("mull", "feature-auth"), "mull_feature_auth");
648        assert_eq!(
649            cfg.db_name("my-project", "add-users"),
650            "my_project_add_users"
651        );
652    }
653
654    #[test]
655    fn test_db_name_case_and_special_chars() {
656        let cfg = DatabaseConfig {
657            url_template: String::new(),
658            setup_command: None,
659            env_var: None,
660        };
661        assert_eq!(cfg.db_name("Mull", "Feature"), "mull_feature");
662        assert_eq!(cfg.db_name("my.project", "feat"), "my_project_feat");
663        assert_eq!(cfg.db_name("has spaces", "feat"), "has_spaces_feat");
664    }
665
666    #[test]
667    fn test_database_url_template() {
668        let cfg = DatabaseConfig {
669            url_template: "postgres:///{{db_name}}".to_string(),
670            setup_command: None,
671            env_var: None,
672        };
673        assert_eq!(
674            cfg.database_url("mull", "feature"),
675            "postgres:///mull_feature"
676        );
677
678        let cfg2 = DatabaseConfig {
679            url_template: "postgres://localhost:5432/{{db_name}}".to_string(),
680            setup_command: None,
681            env_var: None,
682        };
683        assert_eq!(
684            cfg2.database_url("mull", "feature"),
685            "postgres://localhost:5432/mull_feature"
686        );
687    }
688
689    #[test]
690    fn test_env_var_name_default() {
691        let cfg = DatabaseConfig {
692            url_template: String::new(),
693            setup_command: None,
694            env_var: None,
695        };
696        assert_eq!(cfg.env_var_name(), "DATABASE_URL");
697    }
698
699    #[test]
700    fn test_env_var_name_custom() {
701        let cfg = DatabaseConfig {
702            url_template: String::new(),
703            setup_command: None,
704            env_var: Some("DB_URL".to_string()),
705        };
706        assert_eq!(cfg.env_var_name(), "DB_URL");
707    }
708
709    #[test]
710    fn test_database_config_deserialization() {
711        let toml_str = r#"
712[projects.myproject]
713path = "/tmp/myproject"
714
715[projects.myproject.database]
716url_template = "postgres:///{{db_name}}"
717setup_command = "cargo sqlx database setup"
718"#;
719        let config: Config = toml::from_str(toml_str).unwrap();
720        let project = config.projects.get("myproject").unwrap();
721        let db = project.database.as_ref().unwrap();
722        assert_eq!(db.url_template, "postgres:///{{db_name}}");
723        assert_eq!(
724            db.setup_command.as_deref(),
725            Some("cargo sqlx database setup")
726        );
727        assert!(db.env_var.is_none());
728    }
729
730    #[test]
731    fn test_database_config_absent_backward_compat() {
732        let toml_str = r#"
733[projects.myproject]
734path = "/tmp/myproject"
735"#;
736        let config: Config = toml::from_str(toml_str).unwrap();
737        let project = config.projects.get("myproject").unwrap();
738        assert!(project.database.is_none());
739    }
740
741    #[test]
742    fn test_database_config_roundtrip() {
743        let db_config = DatabaseConfig {
744            url_template: "postgres:///{{db_name}}".to_string(),
745            setup_command: Some("cargo sqlx database setup".to_string()),
746            env_var: Some("DB_URL".to_string()),
747        };
748        let config = Config {
749            projects: {
750                let mut m = BTreeMap::new();
751                m.insert(
752                    "myproject".to_string(),
753                    Project {
754                        path: PathBuf::from("/tmp/myproject"),
755                        worktree_base: None,
756                        database: Some(db_config.clone()),
757                        hooks: None,
758                    },
759                );
760                m
761            },
762        };
763        let serialized = toml::to_string_pretty(&config).unwrap();
764        let deserialized: Config = toml::from_str(&serialized).unwrap();
765        let project = deserialized.projects.get("myproject").unwrap();
766        assert_eq!(project.database.as_ref(), Some(&db_config));
767    }
768
769    #[test]
770    fn test_hooks_config_deserialization() {
771        let toml_str = r#"
772[projects.myproject]
773path = "/tmp/myproject"
774
775[projects.myproject.hooks]
776post_create = ["yarn install", "cargo fetch"]
777"#;
778        let config: Config = toml::from_str(toml_str).unwrap();
779        let project = config.projects.get("myproject").unwrap();
780        let hooks = project.hooks.as_ref().unwrap();
781        assert_eq!(hooks.post_create.len(), 2);
782        assert_eq!(hooks.post_create[0], "yarn install");
783        assert_eq!(hooks.post_create[1], "cargo fetch");
784    }
785
786    #[test]
787    fn test_hooks_config_absent_backward_compat() {
788        let toml_str = r#"
789[projects.myproject]
790path = "/tmp/myproject"
791"#;
792        let config: Config = toml::from_str(toml_str).unwrap();
793        let project = config.projects.get("myproject").unwrap();
794        assert!(project.hooks.is_none());
795    }
796
797    #[test]
798    fn test_hooks_config_empty_list() {
799        let toml_str = r#"
800[projects.myproject]
801path = "/tmp/myproject"
802
803[projects.myproject.hooks]
804post_create = []
805"#;
806        let config: Config = toml::from_str(toml_str).unwrap();
807        let project = config.projects.get("myproject").unwrap();
808        assert_eq!(
809            project.hooks.as_ref(),
810            Some(&HooksConfig {
811                post_create: vec![]
812            })
813        );
814    }
815
816    #[test]
817    fn test_hooks_config_roundtrip() {
818        let hooks = HooksConfig {
819            post_create: vec!["yarn install".to_string(), "cargo fetch".to_string()],
820        };
821        let config = Config {
822            projects: {
823                let mut m = BTreeMap::new();
824                m.insert(
825                    "myproject".to_string(),
826                    Project {
827                        path: PathBuf::from("/tmp/myproject"),
828                        worktree_base: None,
829                        database: None,
830                        hooks: Some(hooks.clone()),
831                    },
832                );
833                m
834            },
835        };
836        let serialized = toml::to_string_pretty(&config).unwrap();
837        let deserialized: Config = toml::from_str(&serialized).unwrap();
838        let project = deserialized.projects.get("myproject").unwrap();
839        assert_eq!(project.hooks.as_ref(), Some(&hooks));
840    }
841
842    #[test]
843    fn test_repo_config_deserialization() {
844        let toml_str = r#"
845name = "mull"
846
847[database]
848url_template = "postgres:///{{db_name}}"
849setup_command = "cargo sqlx database setup"
850
851[hooks]
852post_create = ["yarn install"]
853
854[env]
855RUST_LOG = "debug"
856NODE_ENV = "development"
857"#;
858        let config: RepoConfig = toml::from_str(toml_str).unwrap();
859        assert_eq!(config.name.as_deref(), Some("mull"));
860        assert!(config.database.is_some());
861        assert_eq!(config.hooks.as_ref().unwrap().post_create.len(), 1);
862        let env = config.env.as_ref().unwrap();
863        assert_eq!(env.get("RUST_LOG").unwrap(), "debug");
864    }
865
866    #[test]
867    fn test_repo_config_minimal() {
868        let toml_str = "";
869        let config: RepoConfig = toml::from_str(toml_str).unwrap();
870        assert!(config.name.is_none());
871        assert!(config.database.is_none());
872        assert!(config.hooks.is_none());
873        assert!(config.env.is_none());
874    }
875
876    #[test]
877    fn test_repo_config_name_only() {
878        let toml_str = r#"name = "myproject""#;
879        let config: RepoConfig = toml::from_str(toml_str).unwrap();
880        assert_eq!(config.name.as_deref(), Some("myproject"));
881    }
882
883    #[test]
884    fn test_effective_name_explicit() {
885        let config = RepoConfig {
886            name: Some("mull".to_string()),
887            ..Default::default()
888        };
889        assert_eq!(
890            config.effective_name(Path::new("/home/user/code/my-repo")),
891            "mull"
892        );
893    }
894
895    #[test]
896    fn test_effective_name_fallback_to_dir() {
897        let config = RepoConfig::default();
898        assert_eq!(
899            config.effective_name(Path::new("/home/user/code/my-repo")),
900            "my-repo"
901        );
902    }
903
904    #[test]
905    fn test_merge_project_repo_only() {
906        let repo = RepoConfig {
907            database: Some(DatabaseConfig {
908                url_template: "postgres:///{{db_name}}".to_string(),
909                setup_command: None,
910                env_var: None,
911            }),
912            hooks: Some(HooksConfig {
913                post_create: vec!["yarn install".to_string()],
914            }),
915            ..Default::default()
916        };
917        let merged = merge_project(Some(&repo), None, PathBuf::from("/tmp/repo"));
918        assert_eq!(merged.path, PathBuf::from("/tmp/repo"));
919        assert!(merged.database.is_some());
920        assert!(merged.hooks.is_some());
921        assert!(merged.worktree_base.is_none());
922    }
923
924    #[test]
925    fn test_merge_project_user_overrides_repo() {
926        let repo = RepoConfig {
927            database: Some(DatabaseConfig {
928                url_template: "postgres:///{{db_name}}".to_string(),
929                setup_command: None,
930                env_var: None,
931            }),
932            ..Default::default()
933        };
934        let user = Project {
935            path: PathBuf::from("/tmp/repo"),
936            worktree_base: Some(PathBuf::from("/tmp/worktrees")),
937            database: Some(DatabaseConfig {
938                url_template: "postgres://localhost/{{db_name}}".to_string(),
939                setup_command: Some("migrate".to_string()),
940                env_var: None,
941            }),
942            hooks: None,
943        };
944        let merged = merge_project(Some(&repo), Some(&user), user.path.clone());
945        assert_eq!(
946            merged.database.as_ref().unwrap().url_template,
947            "postgres://localhost/{{db_name}}"
948        );
949        assert_eq!(merged.worktree_base, Some(PathBuf::from("/tmp/worktrees")));
950        assert!(merged.hooks.is_none());
951    }
952
953    #[test]
954    fn test_merge_project_repo_fills_gaps() {
955        let repo = RepoConfig {
956            hooks: Some(HooksConfig {
957                post_create: vec!["setup.sh".to_string()],
958            }),
959            ..Default::default()
960        };
961        let user = Project {
962            path: PathBuf::from("/tmp/repo"),
963            worktree_base: None,
964            database: None,
965            hooks: None,
966        };
967        let merged = merge_project(Some(&repo), Some(&user), user.path.clone());
968        assert!(merged.hooks.is_some());
969        assert_eq!(merged.hooks.unwrap().post_create, vec!["setup.sh"]);
970    }
971
972    #[test]
973    fn test_load_merged_env_repo_layer() {
974        let repo_env: BTreeMap<String, String> =
975            [("REPO_VAR".to_string(), "from_repo".to_string())]
976                .into_iter()
977                .collect();
978
979        // EnvVars::load returns empty when no file exists (returns default).
980        // The repo env layer should come through as the base.
981        let merged = load_merged_env("nonexistent_test_project_12345", None, &repo_env).unwrap();
982        assert_eq!(merged.len(), 1);
983        assert_eq!(merged[0].key, "REPO_VAR");
984        assert_eq!(merged[0].value, "from_repo");
985        assert!(matches!(merged[0].source, EnvSource::Repo));
986    }
987
988    #[test]
989    fn test_load_merged_env_empty_layers() {
990        let repo_env = BTreeMap::new();
991        let merged = load_merged_env("nonexistent_test_project_12345", None, &repo_env).unwrap();
992        assert!(merged.is_empty());
993    }
994
995    #[test]
996    fn test_resolve_main_repo_from_dot_git_file() {
997        let tmp = tempfile::TempDir::new().unwrap();
998        let main_repo = tmp.path().join("main-repo");
999        let worktree = tmp.path().join("worktrees").join("feature");
1000        let git_worktrees = main_repo.join(".git").join("worktrees").join("feature");
1001
1002        fs::create_dir_all(&git_worktrees).unwrap();
1003        fs::create_dir_all(&worktree).unwrap();
1004
1005        let gitdir_content = format!("gitdir: {}", git_worktrees.display());
1006        fs::write(worktree.join(".git"), &gitdir_content).unwrap();
1007
1008        let result = resolve_main_repo_from_dot_git_file(&worktree.join(".git")).unwrap();
1009        assert!(result.is_some());
1010        assert_eq!(result.unwrap(), main_repo.canonicalize().unwrap());
1011    }
1012
1013    #[test]
1014    fn test_resolve_main_repo_from_dot_git_file_relative() {
1015        let tmp = tempfile::TempDir::new().unwrap();
1016        let main_repo = tmp.path().join("repos").join("main");
1017        let worktree = main_repo.join(".worktrees").join("feature");
1018        let git_worktrees = main_repo.join(".git").join("worktrees").join("feature");
1019
1020        fs::create_dir_all(&git_worktrees).unwrap();
1021        fs::create_dir_all(&worktree).unwrap();
1022
1023        let gitdir_content = "gitdir: ../../.git/worktrees/feature";
1024        fs::write(worktree.join(".git"), gitdir_content).unwrap();
1025
1026        let result = resolve_main_repo_from_dot_git_file(&worktree.join(".git")).unwrap();
1027        assert!(result.is_some());
1028        assert_eq!(result.unwrap(), main_repo.canonicalize().unwrap());
1029    }
1030
1031    #[test]
1032    fn test_resolve_main_repo_invalid_format() {
1033        let tmp = tempfile::TempDir::new().unwrap();
1034        let dot_git = tmp.path().join(".git");
1035        fs::write(&dot_git, "not a valid gitdir line").unwrap();
1036
1037        let result = resolve_main_repo_from_dot_git_file(&dot_git).unwrap();
1038        assert!(result.is_none());
1039    }
1040}