Skip to main content

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::{self, File};
18use std::io::Write;
19use std::path::{Path, PathBuf};
20
21use fs4::FileExt;
22use serde::{Deserialize, Serialize};
23
24use crate::error::{Error, Result};
25
26#[derive(Debug)]
27pub struct ProjectRef {
28    pub project: String,
29    pub worktree: Option<String>,
30}
31
32impl ProjectRef {
33    pub fn parse(input: &str) -> Result<Self> {
34        let parts: Vec<&str> = input.split('/').collect();
35        match parts.as_slice() {
36            [project] if !project.is_empty() => Ok(Self {
37                project: (*project).to_string(),
38                worktree: None,
39            }),
40            [project, worktree] if !project.is_empty() && !worktree.is_empty() => Ok(Self {
41                project: (*project).to_string(),
42                worktree: Some((*worktree).to_string()),
43            }),
44            _ => Err(Error::InvalidProjectRef(input.to_string())),
45        }
46    }
47}
48
49#[derive(Debug, Default, Serialize, Deserialize)]
50pub struct Config {
51    #[serde(default)]
52    pub projects: BTreeMap<String, Project>,
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct DatabaseConfig {
57    pub url_template: String,
58    #[serde(default)]
59    pub setup_command: Option<String>,
60    #[serde(default)]
61    pub env_var: Option<String>,
62}
63
64impl DatabaseConfig {
65    #[allow(clippy::unused_self)]
66    pub fn db_name(&self, project: &str, worktree: &str) -> String {
67        let raw = format!("{project}_{worktree}");
68        raw.chars()
69            .map(|c| {
70                if c.is_ascii_alphanumeric() || c == '_' {
71                    c
72                } else {
73                    '_'
74                }
75            })
76            .collect::<String>()
77            .to_lowercase()
78    }
79
80    pub fn database_url(&self, project: &str, worktree: &str) -> String {
81        let db_name = self.db_name(project, worktree);
82        self.url_template.replace("{{db_name}}", &db_name)
83    }
84
85    pub fn env_var_name(&self) -> &str {
86        self.env_var.as_deref().unwrap_or("DATABASE_URL")
87    }
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
91pub struct HooksConfig {
92    #[serde(default)]
93    pub post_create: Vec<String>,
94}
95
96#[derive(Debug, Default, Clone, Serialize, Deserialize)]
97pub struct RepoConfig {
98    pub name: Option<String>,
99    #[serde(default)]
100    pub database: Option<DatabaseConfig>,
101    #[serde(default)]
102    pub hooks: Option<HooksConfig>,
103    #[serde(default)]
104    pub env: Option<BTreeMap<String, String>>,
105}
106
107impl RepoConfig {
108    pub fn load_from_dir(dir: &Path) -> Result<Option<Self>> {
109        let config_path = dir.join(".grove").join("config.toml");
110        if !config_path.exists() {
111            return Ok(None);
112        }
113        let content = fs::read_to_string(&config_path)?;
114        let config: RepoConfig = toml::from_str(&content)?;
115        Ok(Some(config))
116    }
117
118    /// Walk up from `path` looking for `.grove/config.toml`.
119    /// Returns `(config, repo_root)` where the second element is the main repo root.
120    /// When `.git` is a file (git worktree) or `.jj/repo` is a symlink (jj workspace),
121    /// resolves back to the main repo root.
122    pub fn discover(path: &Path) -> Result<Option<(Self, PathBuf)>> {
123        let mut current = path.canonicalize()?;
124        loop {
125            if let Some(config) = Self::load_from_dir(&current)? {
126                let dot_git = current.join(".git");
127                if dot_git.is_file() {
128                    // Git worktree — resolve .git file to find main repo
129                    if let Some(main_repo) = resolve_main_repo_from_dot_git_file(&dot_git)? {
130                        return Ok(Some((config, main_repo)));
131                    }
132                    // Resolution failed — fall through to continue walking up
133                } else {
134                    // Check if this is a jj workspace (not the main repo)
135                    let dot_jj_repo = current.join(".jj").join("repo");
136                    if dot_jj_repo.is_symlink() {
137                        if let Some(main_repo) = resolve_main_repo_from_jj_workspace(&dot_jj_repo) {
138                            return Ok(Some((config, main_repo)));
139                        }
140                    }
141                    return Ok(Some((config, current)));
142                }
143            }
144            if !current.pop() {
145                return Ok(None);
146            }
147        }
148    }
149
150    pub fn effective_name(&self, repo_root: &Path) -> String {
151        self.name.clone().unwrap_or_else(|| {
152            repo_root.file_name().map_or_else(
153                || "unknown".to_string(),
154                |n| n.to_string_lossy().to_string(),
155            )
156        })
157    }
158}
159
160/// Resolve a git worktree's `.git` file to find the main repository root.
161///
162/// In a git worktree, `.git` is a file containing `gitdir: <path>`, where
163/// `<path>` points to `<main_repo>/.git/worktrees/<name>`. Walks up the gitdir
164/// path to find the `.git` component and returns its parent as the main repo root.
165fn resolve_main_repo_from_dot_git_file(dot_git_file: &Path) -> Result<Option<PathBuf>> {
166    let content = fs::read_to_string(dot_git_file)?;
167    let Some(gitdir_str) = content.trim().strip_prefix("gitdir: ") else {
168        return Ok(None);
169    };
170
171    let base_dir = dot_git_file
172        .parent()
173        .expect("dot_git_file always has a parent directory");
174    let gitdir_path = if Path::new(gitdir_str).is_absolute() {
175        PathBuf::from(gitdir_str)
176    } else {
177        base_dir.join(gitdir_str)
178    };
179
180    let Ok(canonical) = gitdir_path.canonicalize() else {
181        return Ok(None);
182    };
183
184    for ancestor in canonical.ancestors() {
185        if ancestor.file_name() == Some(std::ffi::OsStr::new(".git")) {
186            return Ok(ancestor.parent().map(Path::to_path_buf));
187        }
188    }
189
190    Ok(None)
191}
192
193/// Resolve a jj workspace's `.jj/repo` symlink to find the main repository root.
194///
195/// In a jj workspace, `.jj/repo` is a symlink pointing to `<main_repo>/.jj/repo`.
196fn resolve_main_repo_from_jj_workspace(dot_jj_repo: &Path) -> Option<PathBuf> {
197    let canonical = dot_jj_repo.canonicalize().ok()?;
198    // canonical points to <main_repo>/.jj/repo
199    let jj_dir = canonical.parent()?;
200    if jj_dir.file_name() == Some(std::ffi::OsStr::new(".jj")) {
201        jj_dir.parent().map(Path::to_path_buf)
202    } else {
203        None
204    }
205}
206
207pub fn merge_project(
208    repo_config: Option<&RepoConfig>,
209    user_project: Option<&Project>,
210    path: PathBuf,
211) -> Project {
212    let repo_db = repo_config.and_then(|rc| rc.database.clone());
213    let repo_hooks = repo_config.and_then(|rc| rc.hooks.clone());
214
215    Project {
216        path,
217        worktree_base: user_project.and_then(|p| p.worktree_base.clone()),
218        database: user_project.and_then(|p| p.database.clone()).or(repo_db),
219        hooks: user_project.and_then(|p| p.hooks.clone()).or(repo_hooks),
220    }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct Project {
225    pub path: PathBuf,
226    #[serde(default)]
227    pub worktree_base: Option<PathBuf>,
228    #[serde(default)]
229    pub database: Option<DatabaseConfig>,
230    #[serde(default)]
231    pub hooks: Option<HooksConfig>,
232}
233
234impl Project {
235    /// Get the worktree base directory for this project.
236    /// Returns the configured `worktree_base`, or defaults to `<project_path>/.worktrees`.
237    pub fn worktree_base(&self) -> PathBuf {
238        self.worktree_base
239            .clone()
240            .unwrap_or_else(|| self.path.join(".worktrees"))
241    }
242}
243
244#[derive(Debug, Default, Serialize, Deserialize)]
245pub struct EnvVars {
246    #[serde(flatten)]
247    pub vars: BTreeMap<String, String>,
248}
249
250fn config_dir() -> Result<PathBuf> {
251    // Allow override via GROVE_CONFIG_DIR for testing
252    if let Ok(dir) = std::env::var("GROVE_CONFIG_DIR") {
253        return Ok(PathBuf::from(dir));
254    }
255    dirs::config_dir()
256        .map(|p| p.join("grove"))
257        .ok_or(Error::NoConfigDir)
258}
259
260fn config_path() -> Result<PathBuf> {
261    Ok(config_dir()?.join("config.toml"))
262}
263
264fn envs_dir() -> Result<PathBuf> {
265    Ok(config_dir()?.join("envs"))
266}
267
268pub(crate) fn env_path(project: &str) -> Result<PathBuf> {
269    Ok(envs_dir()?.join(format!("{project}.toml")))
270}
271
272pub(crate) fn worktree_env_path(project: &str, worktree: &str) -> Result<PathBuf> {
273    Ok(envs_dir()?.join(project).join(format!("{worktree}.toml")))
274}
275
276fn config_lock_path() -> Result<PathBuf> {
277    Ok(config_dir()?.join("config.toml.lock"))
278}
279
280/// Acquire the exclusive advisory lock guarding the global config file.
281///
282/// Held for the whole read-modify-write cycle so two grove processes can't
283/// each load the registry, add a different project, and clobber one another.
284/// The lock is released when the returned handle is dropped.
285fn lock_config() -> Result<File> {
286    let path = config_lock_path()?;
287    if let Some(parent) = path.parent() {
288        fs::create_dir_all(parent)?;
289    }
290    let file = File::create(&path)?;
291    // Fully qualified so this keeps resolving to fs4's trait method rather than
292    // std's inherent `File::lock`, which shares the name.
293    <File as FileExt>::lock(&file)?;
294    Ok(file)
295}
296
297/// Write `content` to `path` atomically: write a sibling temp file, fsync it,
298/// then rename it over the destination. A concurrent reader sees either the old
299/// file or the new one, never a truncated one.
300fn write_atomic(path: &Path, content: &str) -> Result<()> {
301    let parent = path.parent().ok_or(Error::NoConfigDir)?;
302    fs::create_dir_all(parent)?;
303
304    let file_name = path.file_name().map_or_else(
305        || "config".to_string(),
306        |name| name.to_string_lossy().to_string(),
307    );
308    // Same directory as the target so the rename stays within one filesystem.
309    let tmp_path = parent.join(format!(".{file_name}.{}.tmp", std::process::id()));
310
311    let result = (|| -> Result<()> {
312        let mut file = File::create(&tmp_path)?;
313        // Replacing a restricted env file must not reset its permissions to
314        // the process umask default when the temp file is renamed into place.
315        if let Ok(metadata) = fs::metadata(path) {
316            file.set_permissions(metadata.permissions())?;
317        }
318        file.write_all(content.as_bytes())?;
319        file.sync_all()?;
320        drop(file);
321        fs::rename(&tmp_path, path)?;
322        Ok(())
323    })();
324
325    if result.is_err() {
326        let _ = fs::remove_file(&tmp_path);
327    }
328    result
329}
330
331impl Config {
332    pub fn load() -> Result<Self> {
333        let path = config_path()?;
334        if !path.exists() {
335            return Ok(Self::default());
336        }
337        let content = fs::read_to_string(&path)?;
338        let config: Config = toml::from_str(&content)?;
339        Ok(config)
340    }
341
342    /// Write the registry to disk atomically.
343    ///
344    /// Callers that read-modify-write the registry should go through
345    /// [`Config::update`] instead, so the whole cycle is serialized against
346    /// other grove processes.
347    pub fn save(&self) -> Result<()> {
348        let path = config_path()?;
349        let content = toml::to_string_pretty(self)?;
350        write_atomic(&path, &content)
351    }
352
353    /// Read-modify-write the registry while holding the exclusive config lock.
354    ///
355    /// `f` receives the config as it exists on disk *right now* — not a copy
356    /// loaded earlier — so concurrent grove processes queue up instead of
357    /// dropping each other's projects.
358    pub fn update<T, F>(f: F) -> Result<T>
359    where
360        F: FnOnce(&mut Self) -> Result<T>,
361    {
362        let _lock = lock_config()?;
363        let mut config = Self::load()?;
364        let value = f(&mut config)?;
365        config.save()?;
366        Ok(value)
367    }
368
369    pub fn add_project(&mut self, name: String, path: PathBuf) -> Result<()> {
370        if self.projects.contains_key(&name) {
371            return Err(Error::ProjectExists(name));
372        }
373
374        // Validate path exists and is a git/jj repo
375        if !path.exists() {
376            return Err(Error::PathNotFound(path));
377        }
378        if !path.join(".git").exists() && !path.join(".jj").exists() {
379            return Err(Error::NotVcsRepo(path));
380        }
381
382        let canonical = path.canonicalize()?;
383        self.projects.insert(
384            name,
385            Project {
386                path: canonical,
387                worktree_base: None,
388                database: None,
389                hooks: None,
390            },
391        );
392        Ok(())
393    }
394
395    /// Register a discovered project to the registry, printing a message to stderr.
396    /// Returns `true` if the project was newly registered, `false` if it was already
397    /// present or could not be saved.
398    ///
399    /// Only ever called for write-intent commands (see [`AutoRegister`]) — read-only
400    /// commands must not mutate the registry, because `grove env export` runs on
401    /// every directory change under the mise integration.
402    ///
403    /// The registry is re-read under the config lock before the insert, so a project
404    /// registered concurrently by another grove process is never dropped. On failure
405    /// this prints a warning and leaves both disk and memory untouched; registration
406    /// is a convenience and must never block the command that triggered it.
407    ///
408    /// Unlike [`add_project`], this skips VCS validation and path canonicalization
409    /// because `discover()` has already verified and canonicalized the path.
410    pub fn register_discovered(&mut self, name: &str, project: Project) -> bool {
411        if self.projects.contains_key(name) {
412            return false;
413        }
414
415        match Self::insert_discovered(name, project) {
416            Ok((fresh, registered)) => {
417                // Adopt the on-disk state so in-memory config matches what was
418                // written, including entries other processes added meanwhile.
419                *self = fresh;
420                if registered {
421                    eprintln!("Registered \"{name}\" to project registry");
422                }
423                registered
424            }
425            Err(e) => {
426                eprintln!("Warning: could not register \"{name}\" to project registry: {e}");
427                false
428            }
429        }
430    }
431
432    /// Insert `project` into the on-disk registry under the config lock.
433    /// Returns the resulting config and whether this call added the entry.
434    fn insert_discovered(name: &str, project: Project) -> Result<(Self, bool)> {
435        let _lock = lock_config()?;
436        let mut config = Self::load()?;
437        if config.projects.contains_key(name) {
438            return Ok((config, false));
439        }
440        config.projects.insert(name.to_string(), project);
441        config.save()?;
442        Ok((config, true))
443    }
444
445    pub fn remove_project(&mut self, name: &str) -> Result<()> {
446        if self.projects.remove(name).is_none() {
447            return Err(Error::ProjectNotFound(name.to_string()));
448        }
449        Ok(())
450    }
451
452    /// Find which project a path belongs to.
453    ///
454    /// Checks in order:
455    /// 1. Path is a subdirectory of a project's worktree base (more specific match)
456    /// 2. Path is a subdirectory of a project's main repo
457    pub fn find_project_for_path(&self, path: &Path) -> Result<Option<ProjectRef>> {
458        let canonical = path.canonicalize()?;
459
460        // First: check if path is in any project's worktree base (more specific match)
461        for (name, project) in &self.projects {
462            let wt_base = project.worktree_base();
463            if let Ok(canonical_base) = wt_base.canonicalize() {
464                if canonical.starts_with(&canonical_base) {
465                    let rel = canonical.strip_prefix(&canonical_base).unwrap();
466                    if let Some(wt_dir) = rel.components().next() {
467                        let wt_name = wt_dir.as_os_str().to_string_lossy().to_string();
468                        return Ok(Some(ProjectRef {
469                            project: name.clone(),
470                            worktree: Some(wt_name),
471                        }));
472                    }
473                }
474            }
475        }
476
477        // Second: check if path is in a project's main repo
478        for (name, project) in &self.projects {
479            if canonical.starts_with(&project.path) {
480                return Ok(Some(ProjectRef {
481                    project: name.clone(),
482                    worktree: None,
483                }));
484            }
485        }
486
487        Ok(None)
488    }
489}
490
491impl EnvVars {
492    pub fn load(project: &str) -> Result<Self> {
493        let path = env_path(project)?;
494        if !path.exists() {
495            return Ok(Self::default());
496        }
497        let content = fs::read_to_string(&path)?;
498        let vars: EnvVars = toml::from_str(&content)?;
499        Ok(vars)
500    }
501
502    pub fn save(&self, project: &str) -> Result<()> {
503        let path = env_path(project)?;
504        let content = toml::to_string_pretty(self)?;
505        write_atomic(&path, &content)
506    }
507
508    pub fn load_worktree(project: &str, worktree: &str) -> Result<Self> {
509        let path = worktree_env_path(project, worktree)?;
510        if !path.exists() {
511            return Ok(Self::default());
512        }
513        let content = fs::read_to_string(&path)?;
514        let vars: Self = toml::from_str(&content)?;
515        Ok(vars)
516    }
517
518    pub fn save_worktree(&self, project: &str, worktree: &str) -> Result<()> {
519        let path = worktree_env_path(project, worktree)?;
520        let content = toml::to_string_pretty(self)?;
521        write_atomic(&path, &content)
522    }
523
524    pub fn set(&mut self, key: String, value: String) {
525        self.vars.insert(key, value);
526    }
527
528    pub fn remove(&mut self, key: &str) -> bool {
529        self.vars.remove(key).is_some()
530    }
531}
532
533#[derive(Debug, Clone, Copy)]
534pub enum EnvSource {
535    Repo,
536    Project,
537    Worktree,
538}
539
540#[derive(Debug)]
541pub struct MergedEnvVar {
542    pub key: String,
543    pub value: String,
544    pub source: EnvSource,
545}
546
547pub fn load_merged_env(
548    project_name: &str,
549    worktree: Option<&str>,
550    repo_env: &BTreeMap<String, String>,
551) -> Result<Vec<MergedEnvVar>> {
552    // Layer 1: repo env vars (base)
553    let mut merged: BTreeMap<String, (String, EnvSource)> = repo_env
554        .iter()
555        .map(|(k, v)| (k.clone(), (v.clone(), EnvSource::Repo)))
556        .collect();
557
558    // Layer 2: user project-level env vars
559    let user_env = EnvVars::load(project_name)?;
560    for (k, v) in user_env.vars {
561        merged.insert(k, (v, EnvSource::Project));
562    }
563
564    // Layer 3: user worktree-level env vars
565    if let Some(wt) = worktree {
566        let wt_env = EnvVars::load_worktree(project_name, wt)?;
567        for (k, v) in wt_env.vars {
568            merged.insert(k, (v, EnvSource::Worktree));
569        }
570    }
571
572    Ok(merged
573        .into_iter()
574        .map(|(key, (value, source))| MergedEnvVar { key, value, source })
575        .collect())
576}
577
578/// Whether a resolver may persist a newly auto-detected project to the registry.
579///
580/// Auto-registration is a side effect on the user's global config, so it is opt-in
581/// per command rather than a property of resolution itself.
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum AutoRegister {
584    /// Write-intent command (`grove start`, `grove worktree new`, `grove env set`,
585    /// `grove env unset`): persist a newly discovered project.
586    Enabled,
587    /// Read-only command (`grove env list`, `grove env export`, `grove worktree list`):
588    /// resolve without touching the registry. `grove env export` in particular runs on
589    /// every directory change under the mise integration, so plain `cd` must never
590    /// mutate persistent state.
591    Disabled,
592}
593
594impl AutoRegister {
595    fn is_enabled(self) -> bool {
596        self == Self::Enabled
597    }
598}
599
600/// Resolve a project by explicit name or auto-detection from cwd.
601/// Returns `(name, project, repo_env_vars)`.
602pub fn resolve_project(
603    config: &mut Config,
604    explicit_name: Option<&str>,
605    auto_register: AutoRegister,
606) -> Result<(String, Project, BTreeMap<String, String>)> {
607    if let Some(name) = explicit_name {
608        if let Some(user_proj) = config.projects.get(name) {
609            let repo_config = RepoConfig::load_from_dir(&user_proj.path)?;
610            let merged = merge_project(
611                repo_config.as_ref(),
612                Some(user_proj),
613                user_proj.path.clone(),
614            );
615            let repo_env = repo_config.and_then(|rc| rc.env).unwrap_or_default();
616            return Ok((name.to_string(), merged, repo_env));
617        }
618
619        // Not registered — try auto-detection from cwd and match by name
620        let cwd = std::env::current_dir()?;
621        if let Some((repo_config, repo_root)) = RepoConfig::discover(&cwd)? {
622            let detected_name = repo_config.effective_name(&repo_root);
623            if detected_name == name {
624                let user_proj = config.projects.get(&detected_name);
625                let path = user_proj.map_or(repo_root, |p| p.path.clone());
626                let merged = merge_project(Some(&repo_config), user_proj, path);
627                let repo_env = repo_config.env.unwrap_or_default();
628
629                if auto_register.is_enabled() {
630                    config.register_discovered(name, merged.clone());
631                }
632
633                return Ok((name.to_string(), merged, repo_env));
634            }
635        }
636
637        return Err(Error::ProjectNotFound(name.to_string()));
638    }
639
640    let cwd = std::env::current_dir()?;
641
642    if let Some(project_ref) = config.find_project_for_path(&cwd)? {
643        let user_proj = config.projects.get(&project_ref.project).unwrap();
644        let repo_config = RepoConfig::load_from_dir(&user_proj.path)?;
645        let merged = merge_project(
646            repo_config.as_ref(),
647            Some(user_proj),
648            user_proj.path.clone(),
649        );
650        let repo_env = repo_config.and_then(|rc| rc.env).unwrap_or_default();
651        return Ok((project_ref.project, merged, repo_env));
652    }
653
654    if let Some((repo_config, repo_root)) = RepoConfig::discover(&cwd)? {
655        let name = repo_config.effective_name(&repo_root);
656        let user_proj = config.projects.get(&name);
657        let path = user_proj.map(|p| p.path.clone()).unwrap_or(repo_root);
658        let merged = merge_project(Some(&repo_config), user_proj, path);
659        let repo_env = repo_config.env.unwrap_or_default();
660
661        if auto_register.is_enabled() {
662            config.register_discovered(&name, merged.clone());
663        }
664
665        return Ok((name, merged, repo_env));
666    }
667
668    Err(Error::NoProjectDetected)
669}
670
671/// Resolved project info for a filesystem path.
672pub type ResolvedProjectForPath = (String, Project, Option<String>, BTreeMap<String, String>);
673
674/// Resolve a project for a filesystem path (used by env export).
675/// Returns `(name, project, worktree_name, repo_env_vars)`.
676pub fn resolve_project_for_path(
677    config: &mut Config,
678    path: &Path,
679    auto_register: AutoRegister,
680) -> Result<Option<ResolvedProjectForPath>> {
681    if let Some(project_ref) = config.find_project_for_path(path)? {
682        let user_proj = config.projects.get(&project_ref.project).unwrap();
683        let repo_config = RepoConfig::load_from_dir(&user_proj.path)?;
684        let merged = merge_project(
685            repo_config.as_ref(),
686            Some(user_proj),
687            user_proj.path.clone(),
688        );
689        let repo_env = repo_config.and_then(|rc| rc.env).unwrap_or_default();
690        return Ok(Some((
691            project_ref.project,
692            merged,
693            project_ref.worktree,
694            repo_env,
695        )));
696    }
697
698    if let Some((repo_config, repo_root)) = RepoConfig::discover(path)? {
699        let name = repo_config.effective_name(&repo_root);
700        let user_proj = config.projects.get(&name);
701        let proj_path = user_proj.map_or_else(|| repo_root.clone(), |p| p.path.clone());
702        let merged = merge_project(Some(&repo_config), user_proj, proj_path);
703
704        let canonical = path.canonicalize()?;
705        let worktree = {
706            let wt_base = merged.worktree_base();
707            if let Ok(canonical_base) = wt_base.canonicalize() {
708                if canonical.starts_with(&canonical_base) {
709                    let rel = canonical.strip_prefix(&canonical_base).unwrap();
710                    rel.components()
711                        .next()
712                        .map(|c| c.as_os_str().to_string_lossy().to_string())
713                } else {
714                    None
715                }
716            } else {
717                None
718            }
719        };
720
721        let repo_env = repo_config.env.unwrap_or_default();
722
723        if auto_register.is_enabled() {
724            config.register_discovered(&name, merged.clone());
725        }
726
727        return Ok(Some((name, merged, worktree, repo_env)));
728    }
729
730    Ok(None)
731}
732
733pub fn export_merged_env(vars: &[MergedEnvVar]) -> String {
734    vars.iter()
735        .map(|var| format!("export {}={}", var.key, shell_escape(&var.value)))
736        .collect::<Vec<_>>()
737        .join("\n")
738}
739
740fn shell_escape(s: &str) -> String {
741    // Always quote for consistency and safety
742    format!("'{}'", s.replace('\'', "'\"'\"'"))
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748
749    #[cfg(unix)]
750    #[test]
751    fn test_atomic_write_preserves_existing_permissions() {
752        use std::os::unix::fs::PermissionsExt;
753
754        let dir = tempfile::TempDir::new().unwrap();
755        let path = dir.path().join("env.toml");
756        fs::write(&path, "old").unwrap();
757        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
758
759        write_atomic(&path, "new").unwrap();
760
761        assert_eq!(fs::read_to_string(&path).unwrap(), "new");
762        assert_eq!(
763            fs::metadata(&path).unwrap().permissions().mode() & 0o777,
764            0o600
765        );
766    }
767
768    #[test]
769    fn test_shell_escape_simple() {
770        assert_eq!(shell_escape("hello"), "'hello'");
771        assert_eq!(shell_escape("/path/to/thing"), "'/path/to/thing'");
772    }
773
774    #[test]
775    fn test_shell_escape_special() {
776        assert_eq!(shell_escape("hello world"), "'hello world'");
777        assert_eq!(shell_escape("it's"), "'it'\"'\"'s'");
778    }
779
780    #[test]
781    fn test_project_ref_parse_project_only() {
782        let pr = ProjectRef::parse("mull").unwrap();
783        assert_eq!(pr.project, "mull");
784        assert!(pr.worktree.is_none());
785    }
786
787    #[test]
788    fn test_project_ref_parse_with_worktree() {
789        let pr = ProjectRef::parse("mull/discord").unwrap();
790        assert_eq!(pr.project, "mull");
791        assert_eq!(pr.worktree.as_deref(), Some("discord"));
792    }
793
794    #[test]
795    fn test_project_ref_parse_too_many_parts() {
796        assert!(ProjectRef::parse("mull/discord/extra").is_err());
797    }
798
799    #[test]
800    fn test_project_ref_parse_leading_slash() {
801        assert!(ProjectRef::parse("/discord").is_err());
802    }
803
804    #[test]
805    fn test_project_ref_parse_trailing_slash() {
806        assert!(ProjectRef::parse("mull/").is_err());
807    }
808
809    #[test]
810    fn test_project_ref_parse_empty() {
811        assert!(ProjectRef::parse("").is_err());
812    }
813
814    #[test]
815    fn test_db_name_basic() {
816        let cfg = DatabaseConfig {
817            url_template: String::new(),
818            setup_command: None,
819            env_var: None,
820        };
821        assert_eq!(cfg.db_name("mull", "feature-auth"), "mull_feature_auth");
822        assert_eq!(
823            cfg.db_name("my-project", "add-users"),
824            "my_project_add_users"
825        );
826    }
827
828    #[test]
829    fn test_db_name_case_and_special_chars() {
830        let cfg = DatabaseConfig {
831            url_template: String::new(),
832            setup_command: None,
833            env_var: None,
834        };
835        assert_eq!(cfg.db_name("Mull", "Feature"), "mull_feature");
836        assert_eq!(cfg.db_name("my.project", "feat"), "my_project_feat");
837        assert_eq!(cfg.db_name("has spaces", "feat"), "has_spaces_feat");
838    }
839
840    #[test]
841    fn test_database_url_template() {
842        let cfg = DatabaseConfig {
843            url_template: "postgres:///{{db_name}}".to_string(),
844            setup_command: None,
845            env_var: None,
846        };
847        assert_eq!(
848            cfg.database_url("mull", "feature"),
849            "postgres:///mull_feature"
850        );
851
852        let cfg2 = DatabaseConfig {
853            url_template: "postgres://localhost:5432/{{db_name}}".to_string(),
854            setup_command: None,
855            env_var: None,
856        };
857        assert_eq!(
858            cfg2.database_url("mull", "feature"),
859            "postgres://localhost:5432/mull_feature"
860        );
861    }
862
863    #[test]
864    fn test_env_var_name_default() {
865        let cfg = DatabaseConfig {
866            url_template: String::new(),
867            setup_command: None,
868            env_var: None,
869        };
870        assert_eq!(cfg.env_var_name(), "DATABASE_URL");
871    }
872
873    #[test]
874    fn test_env_var_name_custom() {
875        let cfg = DatabaseConfig {
876            url_template: String::new(),
877            setup_command: None,
878            env_var: Some("DB_URL".to_string()),
879        };
880        assert_eq!(cfg.env_var_name(), "DB_URL");
881    }
882
883    #[test]
884    fn test_database_config_deserialization() {
885        let toml_str = r#"
886[projects.myproject]
887path = "/tmp/myproject"
888
889[projects.myproject.database]
890url_template = "postgres:///{{db_name}}"
891setup_command = "cargo sqlx database setup"
892"#;
893        let config: Config = toml::from_str(toml_str).unwrap();
894        let project = config.projects.get("myproject").unwrap();
895        let db = project.database.as_ref().unwrap();
896        assert_eq!(db.url_template, "postgres:///{{db_name}}");
897        assert_eq!(
898            db.setup_command.as_deref(),
899            Some("cargo sqlx database setup")
900        );
901        assert!(db.env_var.is_none());
902    }
903
904    #[test]
905    fn test_database_config_absent_backward_compat() {
906        let toml_str = r#"
907[projects.myproject]
908path = "/tmp/myproject"
909"#;
910        let config: Config = toml::from_str(toml_str).unwrap();
911        let project = config.projects.get("myproject").unwrap();
912        assert!(project.database.is_none());
913    }
914
915    #[test]
916    fn test_database_config_roundtrip() {
917        let db_config = DatabaseConfig {
918            url_template: "postgres:///{{db_name}}".to_string(),
919            setup_command: Some("cargo sqlx database setup".to_string()),
920            env_var: Some("DB_URL".to_string()),
921        };
922        let config = Config {
923            projects: {
924                let mut m = BTreeMap::new();
925                m.insert(
926                    "myproject".to_string(),
927                    Project {
928                        path: PathBuf::from("/tmp/myproject"),
929                        worktree_base: None,
930                        database: Some(db_config.clone()),
931                        hooks: None,
932                    },
933                );
934                m
935            },
936        };
937        let serialized = toml::to_string_pretty(&config).unwrap();
938        let deserialized: Config = toml::from_str(&serialized).unwrap();
939        let project = deserialized.projects.get("myproject").unwrap();
940        assert_eq!(project.database.as_ref(), Some(&db_config));
941    }
942
943    #[test]
944    fn test_hooks_config_deserialization() {
945        let toml_str = r#"
946[projects.myproject]
947path = "/tmp/myproject"
948
949[projects.myproject.hooks]
950post_create = ["yarn install", "cargo fetch"]
951"#;
952        let config: Config = toml::from_str(toml_str).unwrap();
953        let project = config.projects.get("myproject").unwrap();
954        let hooks = project.hooks.as_ref().unwrap();
955        assert_eq!(hooks.post_create.len(), 2);
956        assert_eq!(hooks.post_create[0], "yarn install");
957        assert_eq!(hooks.post_create[1], "cargo fetch");
958    }
959
960    #[test]
961    fn test_hooks_config_absent_backward_compat() {
962        let toml_str = r#"
963[projects.myproject]
964path = "/tmp/myproject"
965"#;
966        let config: Config = toml::from_str(toml_str).unwrap();
967        let project = config.projects.get("myproject").unwrap();
968        assert!(project.hooks.is_none());
969    }
970
971    #[test]
972    fn test_hooks_config_empty_list() {
973        let toml_str = r#"
974[projects.myproject]
975path = "/tmp/myproject"
976
977[projects.myproject.hooks]
978post_create = []
979"#;
980        let config: Config = toml::from_str(toml_str).unwrap();
981        let project = config.projects.get("myproject").unwrap();
982        assert_eq!(
983            project.hooks.as_ref(),
984            Some(&HooksConfig {
985                post_create: vec![]
986            })
987        );
988    }
989
990    #[test]
991    fn test_hooks_config_roundtrip() {
992        let hooks = HooksConfig {
993            post_create: vec!["yarn install".to_string(), "cargo fetch".to_string()],
994        };
995        let config = Config {
996            projects: {
997                let mut m = BTreeMap::new();
998                m.insert(
999                    "myproject".to_string(),
1000                    Project {
1001                        path: PathBuf::from("/tmp/myproject"),
1002                        worktree_base: None,
1003                        database: None,
1004                        hooks: Some(hooks.clone()),
1005                    },
1006                );
1007                m
1008            },
1009        };
1010        let serialized = toml::to_string_pretty(&config).unwrap();
1011        let deserialized: Config = toml::from_str(&serialized).unwrap();
1012        let project = deserialized.projects.get("myproject").unwrap();
1013        assert_eq!(project.hooks.as_ref(), Some(&hooks));
1014    }
1015
1016    #[test]
1017    fn test_repo_config_deserialization() {
1018        let toml_str = r#"
1019name = "mull"
1020
1021[database]
1022url_template = "postgres:///{{db_name}}"
1023setup_command = "cargo sqlx database setup"
1024
1025[hooks]
1026post_create = ["yarn install"]
1027
1028[env]
1029RUST_LOG = "debug"
1030NODE_ENV = "development"
1031"#;
1032        let config: RepoConfig = toml::from_str(toml_str).unwrap();
1033        assert_eq!(config.name.as_deref(), Some("mull"));
1034        assert!(config.database.is_some());
1035        assert_eq!(config.hooks.as_ref().unwrap().post_create.len(), 1);
1036        let env = config.env.as_ref().unwrap();
1037        assert_eq!(env.get("RUST_LOG").unwrap(), "debug");
1038    }
1039
1040    #[test]
1041    fn test_repo_config_minimal() {
1042        let toml_str = "";
1043        let config: RepoConfig = toml::from_str(toml_str).unwrap();
1044        assert!(config.name.is_none());
1045        assert!(config.database.is_none());
1046        assert!(config.hooks.is_none());
1047        assert!(config.env.is_none());
1048    }
1049
1050    #[test]
1051    fn test_repo_config_name_only() {
1052        let toml_str = r#"name = "myproject""#;
1053        let config: RepoConfig = toml::from_str(toml_str).unwrap();
1054        assert_eq!(config.name.as_deref(), Some("myproject"));
1055    }
1056
1057    #[test]
1058    fn test_effective_name_explicit() {
1059        let config = RepoConfig {
1060            name: Some("mull".to_string()),
1061            ..Default::default()
1062        };
1063        assert_eq!(
1064            config.effective_name(Path::new("/home/user/code/my-repo")),
1065            "mull"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_effective_name_fallback_to_dir() {
1071        let config = RepoConfig::default();
1072        assert_eq!(
1073            config.effective_name(Path::new("/home/user/code/my-repo")),
1074            "my-repo"
1075        );
1076    }
1077
1078    #[test]
1079    fn test_merge_project_repo_only() {
1080        let repo = RepoConfig {
1081            database: Some(DatabaseConfig {
1082                url_template: "postgres:///{{db_name}}".to_string(),
1083                setup_command: None,
1084                env_var: None,
1085            }),
1086            hooks: Some(HooksConfig {
1087                post_create: vec!["yarn install".to_string()],
1088            }),
1089            ..Default::default()
1090        };
1091        let merged = merge_project(Some(&repo), None, PathBuf::from("/tmp/repo"));
1092        assert_eq!(merged.path, PathBuf::from("/tmp/repo"));
1093        assert!(merged.database.is_some());
1094        assert!(merged.hooks.is_some());
1095        assert!(merged.worktree_base.is_none());
1096    }
1097
1098    #[test]
1099    fn test_merge_project_user_overrides_repo() {
1100        let repo = RepoConfig {
1101            database: Some(DatabaseConfig {
1102                url_template: "postgres:///{{db_name}}".to_string(),
1103                setup_command: None,
1104                env_var: None,
1105            }),
1106            ..Default::default()
1107        };
1108        let user = Project {
1109            path: PathBuf::from("/tmp/repo"),
1110            worktree_base: Some(PathBuf::from("/tmp/worktrees")),
1111            database: Some(DatabaseConfig {
1112                url_template: "postgres://localhost/{{db_name}}".to_string(),
1113                setup_command: Some("migrate".to_string()),
1114                env_var: None,
1115            }),
1116            hooks: None,
1117        };
1118        let merged = merge_project(Some(&repo), Some(&user), user.path.clone());
1119        assert_eq!(
1120            merged.database.as_ref().unwrap().url_template,
1121            "postgres://localhost/{{db_name}}"
1122        );
1123        assert_eq!(merged.worktree_base, Some(PathBuf::from("/tmp/worktrees")));
1124        assert!(merged.hooks.is_none());
1125    }
1126
1127    #[test]
1128    fn test_merge_project_repo_fills_gaps() {
1129        let repo = RepoConfig {
1130            hooks: Some(HooksConfig {
1131                post_create: vec!["setup.sh".to_string()],
1132            }),
1133            ..Default::default()
1134        };
1135        let user = Project {
1136            path: PathBuf::from("/tmp/repo"),
1137            worktree_base: None,
1138            database: None,
1139            hooks: None,
1140        };
1141        let merged = merge_project(Some(&repo), Some(&user), user.path.clone());
1142        assert!(merged.hooks.is_some());
1143        assert_eq!(merged.hooks.unwrap().post_create, vec!["setup.sh"]);
1144    }
1145
1146    #[test]
1147    fn test_load_merged_env_repo_layer() {
1148        let repo_env: BTreeMap<String, String> =
1149            [("REPO_VAR".to_string(), "from_repo".to_string())]
1150                .into_iter()
1151                .collect();
1152
1153        // EnvVars::load returns empty when no file exists (returns default).
1154        // The repo env layer should come through as the base.
1155        let merged = load_merged_env("nonexistent_test_project_12345", None, &repo_env).unwrap();
1156        assert_eq!(merged.len(), 1);
1157        assert_eq!(merged[0].key, "REPO_VAR");
1158        assert_eq!(merged[0].value, "from_repo");
1159        assert!(matches!(merged[0].source, EnvSource::Repo));
1160    }
1161
1162    #[test]
1163    fn test_load_merged_env_empty_layers() {
1164        let repo_env = BTreeMap::new();
1165        let merged = load_merged_env("nonexistent_test_project_12345", None, &repo_env).unwrap();
1166        assert!(merged.is_empty());
1167    }
1168
1169    #[test]
1170    fn test_resolve_main_repo_from_dot_git_file() {
1171        let tmp = tempfile::TempDir::new().unwrap();
1172        let main_repo = tmp.path().join("main-repo");
1173        let worktree = tmp.path().join("worktrees").join("feature");
1174        let git_worktrees = main_repo.join(".git").join("worktrees").join("feature");
1175
1176        fs::create_dir_all(&git_worktrees).unwrap();
1177        fs::create_dir_all(&worktree).unwrap();
1178
1179        let gitdir_content = format!("gitdir: {}", git_worktrees.display());
1180        fs::write(worktree.join(".git"), &gitdir_content).unwrap();
1181
1182        let result = resolve_main_repo_from_dot_git_file(&worktree.join(".git")).unwrap();
1183        assert!(result.is_some());
1184        assert_eq!(result.unwrap(), main_repo.canonicalize().unwrap());
1185    }
1186
1187    #[test]
1188    fn test_resolve_main_repo_from_dot_git_file_relative() {
1189        let tmp = tempfile::TempDir::new().unwrap();
1190        let main_repo = tmp.path().join("repos").join("main");
1191        let worktree = main_repo.join(".worktrees").join("feature");
1192        let git_worktrees = main_repo.join(".git").join("worktrees").join("feature");
1193
1194        fs::create_dir_all(&git_worktrees).unwrap();
1195        fs::create_dir_all(&worktree).unwrap();
1196
1197        let gitdir_content = "gitdir: ../../.git/worktrees/feature";
1198        fs::write(worktree.join(".git"), gitdir_content).unwrap();
1199
1200        let result = resolve_main_repo_from_dot_git_file(&worktree.join(".git")).unwrap();
1201        assert!(result.is_some());
1202        assert_eq!(result.unwrap(), main_repo.canonicalize().unwrap());
1203    }
1204
1205    #[test]
1206    fn test_resolve_main_repo_invalid_format() {
1207        let tmp = tempfile::TempDir::new().unwrap();
1208        let dot_git = tmp.path().join(".git");
1209        fs::write(&dot_git, "not a valid gitdir line").unwrap();
1210
1211        let result = resolve_main_repo_from_dot_git_file(&dot_git).unwrap();
1212        assert!(result.is_none());
1213    }
1214}