1use 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 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(¤t)? {
124 let dot_git = current.join(".git");
125 if dot_git.is_file() {
126 if let Some(main_repo) = resolve_main_repo_from_dot_git_file(&dot_git)? {
128 return Ok(Some((config, main_repo)));
129 }
130 } else {
132 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
158fn 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
191fn resolve_main_repo_from_jj_workspace(dot_jj_repo: &Path) -> Option<PathBuf> {
195 let canonical = dot_jj_repo.canonicalize().ok()?;
196 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 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 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 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 pub fn find_project_for_path(&self, path: &Path) -> Result<Option<ProjectRef>> {
334 let canonical = path.canonicalize()?;
335
336 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 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 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 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 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
462pub 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 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
522pub type ResolvedProjectForPath = (String, Project, Option<String>, BTreeMap<String, String>);
524
525pub 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 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 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}