1use 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 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(¤t)? {
126 let dot_git = current.join(".git");
127 if dot_git.is_file() {
128 if let Some(main_repo) = resolve_main_repo_from_dot_git_file(&dot_git)? {
130 return Ok(Some((config, main_repo)));
131 }
132 } else {
134 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
160fn 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
193fn resolve_main_repo_from_jj_workspace(dot_jj_repo: &Path) -> Option<PathBuf> {
197 let canonical = dot_jj_repo.canonicalize().ok()?;
198 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 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 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
280fn 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 <File as FileExt>::lock(&file)?;
294 Ok(file)
295}
296
297fn 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 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 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 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 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 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 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 *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 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 pub fn find_project_for_path(&self, path: &Path) -> Result<Option<ProjectRef>> {
458 let canonical = path.canonicalize()?;
459
460 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum AutoRegister {
584 Enabled,
587 Disabled,
592}
593
594impl AutoRegister {
595 fn is_enabled(self) -> bool {
596 self == Self::Enabled
597 }
598}
599
600pub 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 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
671pub type ResolvedProjectForPath = (String, Project, Option<String>, BTreeMap<String, String>);
673
674pub 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 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 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}