Skip to main content

mars_agents/cli/
init.rs

1//! `mars init [TARGET] [--link DIR...]` — scaffold a mars project.
2//!
3//! Creates `<project-root>/mars.toml` and `<project-root>/TARGET` (default: `.agents`).
4//! Use `--root` to select an explicit project root.
5//!
6//! Idempotent: re-running is a no-op for initialization but still processes
7//! `--link` flags.
8
9use std::path::Path;
10
11use crate::error::{ConfigError, MarsError};
12
13use super::output;
14
15/// Arguments for `mars init`.
16#[derive(Debug, clap::Args)]
17pub struct InitArgs {
18    /// Directory name to create for managed output (default: .agents).
19    pub target: Option<String>,
20
21    /// Directories to link after initialization. Repeatable.
22    #[arg(long, value_name = "DIR")]
23    pub link: Vec<String>,
24}
25
26/// Validate that a target is a simple directory name, not a path.
27fn validate_target(target: &str) -> Result<(), MarsError> {
28    if target.contains('/') || target.contains('\\') {
29        return Err(MarsError::Config(ConfigError::Invalid {
30            message: format!(
31                "`{target}` looks like a path — TARGET should be a directory name \
32                 like `.agents` or `.claude`. Use `--root` to specify project root."
33            ),
34        }));
35    }
36    if target == "." || target == ".." || target.is_empty() {
37        return Err(MarsError::Config(ConfigError::Invalid {
38            message: format!(
39                "`{target}` is not a valid target name — use a directory name like `.agents` or `.claude`."
40            ),
41        }));
42    }
43    Ok(())
44}
45
46fn ensure_consumer_config(project_root: &Path) -> Result<bool, MarsError> {
47    let config_path = project_root.join("mars.toml");
48    if config_path.exists() {
49        return Ok(true);
50    }
51
52    crate::fs::atomic_write(&config_path, b"[dependencies]\n")?;
53    Ok(false)
54}
55
56/// Run `mars init`.
57pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
58    // 1. Determine project root
59    let project_root = explicit_root.map(Path::to_path_buf).unwrap_or_else(|| {
60        super::default_project_root().unwrap_or_else(|_| std::env::current_dir().unwrap())
61    });
62
63    // 2. Determine target: argument → existing settings.managed_root → .agents
64    let target = if let Some(t) = args.target.as_deref() {
65        t.to_string()
66    } else {
67        // Check existing config for persisted managed_root
68        match crate::config::load(&project_root) {
69            Ok(config) => config
70                .settings
71                .managed_root
72                .unwrap_or_else(|| ".agents".into()),
73            Err(_) => ".agents".into(),
74        }
75    };
76
77    validate_target(&target)?;
78    let managed_root = project_root.join(&target);
79
80    // 3. Ensure project config + managed structure
81    std::fs::create_dir_all(&managed_root)?;
82    std::fs::create_dir_all(project_root.join(".mars"))?;
83
84    let already_initialized = ensure_consumer_config(&project_root)?;
85
86    // 4. Persist settings.managed_root.
87    persist_managed_root(&project_root, &target)?;
88
89    ensure_local_gitignored(&project_root)?;
90    ensure_mars_dir_gitignored(&project_root)?;
91
92    if !json {
93        if already_initialized {
94            output::print_info(&format!("{} already initialized", project_root.display()));
95        } else {
96            output::print_success(&format!(
97                "initialized {} with mars.toml",
98                project_root.display()
99            ));
100        }
101    }
102
103    // 5. Process --link flags
104    if !args.link.is_empty() {
105        let ctx = super::MarsContext::from_roots(project_root.clone(), managed_root.clone())?;
106        for link_target in &args.link {
107            let link_args = super::link::LinkArgs {
108                target: link_target.clone(),
109                unlink: false,
110                force: false,
111            };
112            super::link::run(&link_args, &ctx, json)?;
113        }
114    }
115
116    if json {
117        output::print_json(&serde_json::json!({
118            "ok": true,
119            "project_root": project_root.to_string_lossy(),
120            "managed_root": managed_root.to_string_lossy(),
121            "already_initialized": already_initialized,
122            "links": args.link,
123        }));
124    }
125
126    Ok(0)
127}
128
129/// Persist managed_root in mars.toml [settings].
130fn persist_managed_root(project_root: &Path, target: &str) -> Result<(), MarsError> {
131    match crate::config::load(project_root) {
132        Ok(mut config) => {
133            config.settings.managed_root = if target == ".agents" {
134                None
135            } else {
136                Some(target.to_string())
137            };
138            crate::config::save(project_root, &config)?;
139        }
140        Err(MarsError::Config(ConfigError::NotFound { .. })) => {
141            // Config will be created by ensure_consumer_config — skip
142        }
143        Err(e) => return Err(e),
144    }
145    Ok(())
146}
147
148/// Ensure mars.local.toml is in the project root .gitignore.
149fn ensure_local_gitignored(project_root: &Path) -> Result<(), MarsError> {
150    ensure_gitignore_entry(project_root, "mars.local.toml")
151}
152
153/// Ensure `.mars/` is in the project root .gitignore.
154fn ensure_mars_dir_gitignored(project_root: &Path) -> Result<(), MarsError> {
155    ensure_gitignore_entry(project_root, ".mars/")
156}
157
158fn ensure_gitignore_entry(project_root: &Path, entry: &str) -> Result<(), MarsError> {
159    let gitignore_path = project_root.join(".gitignore");
160
161    if gitignore_path.exists() {
162        let content = std::fs::read_to_string(&gitignore_path)?;
163        if content.lines().any(|line| line.trim() == entry) {
164            return Ok(());
165        }
166        let mut new_content = content;
167        if !new_content.ends_with('\n') && !new_content.is_empty() {
168            new_content.push('\n');
169        }
170        new_content.push_str(entry);
171        new_content.push('\n');
172        crate::fs::atomic_write(&gitignore_path, new_content.as_bytes())?;
173    } else {
174        crate::fs::atomic_write(&gitignore_path, format!("{entry}\n").as_bytes())?;
175    }
176
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use tempfile::TempDir;
184
185    #[test]
186    fn validate_target_accepts_simple_names() {
187        assert!(validate_target(".agents").is_ok());
188        assert!(validate_target(".claude").is_ok());
189        assert!(validate_target("my-agents").is_ok());
190    }
191
192    #[test]
193    fn validate_target_rejects_paths() {
194        assert!(validate_target("./foo").is_err());
195        assert!(validate_target("foo/bar").is_err());
196        assert!(validate_target("/absolute/path").is_err());
197    }
198
199    #[test]
200    fn validate_target_rejects_dots() {
201        assert!(validate_target(".").is_err());
202        assert!(validate_target("..").is_err());
203    }
204
205    #[test]
206    fn validate_target_rejects_empty() {
207        assert!(validate_target("").is_err());
208    }
209
210    #[test]
211    fn ensure_consumer_config_creates_root_mars_toml() {
212        let dir = TempDir::new().unwrap();
213
214        let already = ensure_consumer_config(dir.path()).unwrap();
215        assert!(!already);
216
217        let content = std::fs::read_to_string(dir.path().join("mars.toml")).unwrap();
218        assert!(content.contains("[dependencies]"));
219    }
220
221    #[test]
222    fn ensure_consumer_config_accepts_existing_mars_toml() {
223        let dir = TempDir::new().unwrap();
224        std::fs::write(
225            dir.path().join("mars.toml"),
226            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
227        )
228        .unwrap();
229
230        let already = ensure_consumer_config(dir.path()).unwrap();
231        assert!(already);
232    }
233
234    #[test]
235    fn ensure_local_gitignored_creates_gitignore() {
236        let dir = TempDir::new().unwrap();
237        ensure_local_gitignored(dir.path()).unwrap();
238
239        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
240        assert!(content.contains("mars.local.toml"));
241    }
242
243    #[test]
244    fn ensure_local_gitignored_idempotent() {
245        let dir = TempDir::new().unwrap();
246        ensure_local_gitignored(dir.path()).unwrap();
247        ensure_local_gitignored(dir.path()).unwrap();
248
249        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
250        assert_eq!(content.matches("mars.local.toml").count(), 1);
251    }
252
253    #[test]
254    fn ensure_mars_dir_gitignored_creates_file() {
255        let dir = TempDir::new().unwrap();
256        ensure_mars_dir_gitignored(dir.path()).unwrap();
257
258        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
259        assert!(content.contains(".mars/"));
260    }
261
262    #[test]
263    fn ensure_mars_dir_gitignored_idempotent() {
264        let dir = TempDir::new().unwrap();
265        ensure_mars_dir_gitignored(dir.path()).unwrap();
266        ensure_mars_dir_gitignored(dir.path()).unwrap();
267
268        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
269        assert_eq!(content.matches(".mars/").count(), 1);
270    }
271}