Skip to main content

mars_agents/cli/
init.rs

1//! `mars init [TARGET] [--link DIR...]` — scaffold a mars-managed directory with `mars.toml`.
2//!
3//! TARGET is a simple directory name (default: `.agents`), not a path.
4//! Creates `<cwd>/TARGET/mars.toml`. Use `--root` for explicit path control.
5//!
6//! Idempotent: re-running when already initialized is a no-op for init
7//! but still processes `--link` flags.
8
9use std::path::Path;
10
11use crate::config::{Config, Settings};
12use crate::error::{ConfigError, MarsError};
13
14use super::output;
15
16/// Arguments for `mars init`.
17#[derive(Debug, clap::Args)]
18pub struct InitArgs {
19    /// Directory name to create (default: .agents). Simple name, not a path.
20    pub target: Option<String>,
21
22    /// Directories to link after initialization. Repeatable.
23    #[arg(long, value_name = "DIR")]
24    pub link: Vec<String>,
25}
26
27/// Validate that a target is a simple directory name, not a path.
28fn validate_target(target: &str) -> Result<(), MarsError> {
29    if target.contains('/') || target.contains('\\') {
30        return Err(MarsError::Config(ConfigError::Invalid {
31            message: format!(
32                "`{target}` looks like a path — TARGET should be a directory name \
33                 like `.agents` or `.claude`. Use `--root` to specify an explicit path."
34            ),
35        }));
36    }
37    if target == "." || target == ".." || target.is_empty() {
38        return Err(MarsError::Config(ConfigError::Invalid {
39            message: format!(
40                "`{target}` is not a valid target name — use a directory name like `.agents` or `.claude`."
41            ),
42        }));
43    }
44    Ok(())
45}
46
47/// Run `mars init`.
48pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
49    // 1. Determine the managed root
50    let managed_root = if let Some(root) = explicit_root {
51        // --root flag: use directly (TARGET is ignored)
52        root.to_path_buf()
53    } else {
54        let target = args.target.as_deref().unwrap_or(".agents");
55        validate_target(target)?;
56        std::env::current_dir()?.join(target)
57    };
58
59    // 2. Idempotency check
60    let config_path = managed_root.join("mars.toml");
61    let already_initialized = config_path.exists();
62
63    if !already_initialized {
64        // 3. Create structure
65        std::fs::create_dir_all(&managed_root)?;
66        std::fs::create_dir_all(managed_root.join(".mars"))?;
67
68        let config = Config {
69            sources: indexmap::IndexMap::new(),
70            settings: Settings::default(),
71        };
72        crate::config::save(&managed_root, &config)?;
73        add_to_gitignore(&managed_root)?;
74
75        if !json {
76            output::print_success(&format!(
77                "initialized {} with mars.toml",
78                managed_root.display()
79            ));
80        }
81    } else {
82        // Already initialized — reconcile required structure
83        std::fs::create_dir_all(managed_root.join(".mars"))?;
84        add_to_gitignore(&managed_root)?;
85
86        if !json {
87            output::print_info(&format!(
88                "{} already initialized",
89                managed_root.display()
90            ));
91        }
92    }
93
94    // 4. Process --link flags
95    if !args.link.is_empty() {
96        let ctx = super::MarsContext::new(managed_root.clone())?;
97        for link_target in &args.link {
98            let link_args = super::link::LinkArgs {
99                target: link_target.clone(),
100                unlink: false,
101                force: false,
102            };
103            super::link::run(&link_args, &ctx, json)?;
104        }
105    }
106
107    if json {
108        output::print_json(&serde_json::json!({
109            "ok": true,
110            "path": managed_root.to_string_lossy(),
111            "already_initialized": already_initialized,
112            "links": args.link,
113        }));
114    }
115
116    Ok(0)
117}
118
119/// Add `.mars/` to `.gitignore` in the agents directory if not already present.
120fn add_to_gitignore(agents_dir: &Path) -> Result<(), MarsError> {
121    let gitignore_path = agents_dir.join(".gitignore");
122    let entry = ".mars/";
123
124    if gitignore_path.exists() {
125        let content = std::fs::read_to_string(&gitignore_path)?;
126        if content.lines().any(|line| line.trim() == entry) {
127            return Ok(());
128        }
129        // Append
130        let mut new_content = content;
131        if !new_content.ends_with('\n') && !new_content.is_empty() {
132            new_content.push('\n');
133        }
134        new_content.push_str(entry);
135        new_content.push('\n');
136        crate::fs::atomic_write(&gitignore_path, new_content.as_bytes())?;
137    } else {
138        crate::fs::atomic_write(&gitignore_path, format!("{entry}\n").as_bytes())?;
139    }
140
141    Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use tempfile::TempDir;
148
149    #[test]
150    fn validate_target_accepts_simple_names() {
151        assert!(validate_target(".agents").is_ok());
152        assert!(validate_target(".claude").is_ok());
153        assert!(validate_target("my-agents").is_ok());
154    }
155
156    #[test]
157    fn validate_target_rejects_paths() {
158        assert!(validate_target("./foo").is_err());
159        assert!(validate_target("foo/bar").is_err());
160        assert!(validate_target("/absolute/path").is_err());
161    }
162
163    #[test]
164    fn validate_target_rejects_dots() {
165        assert!(validate_target(".").is_err());
166        assert!(validate_target("..").is_err());
167    }
168
169    #[test]
170    fn validate_target_rejects_empty() {
171        assert!(validate_target("").is_err());
172    }
173
174    #[test]
175    fn init_creates_agents_toml() {
176        let dir = TempDir::new().unwrap();
177        let agents_dir = dir.path().join(".agents");
178
179        let args = InitArgs {
180            target: None,
181            link: vec![],
182        };
183
184        // We can't easily test run() because it uses current_dir(),
185        // but we can test with --root equivalent
186        std::fs::create_dir_all(&agents_dir).unwrap();
187        let config = Config {
188            sources: indexmap::IndexMap::new(),
189            settings: Settings::default(),
190        };
191        crate::config::save(&agents_dir, &config).unwrap();
192
193        // Verify the file was created correctly
194        assert!(agents_dir.join("mars.toml").exists());
195        let _ = args; // suppress unused warning
196    }
197
198    #[test]
199    fn add_to_gitignore_creates_file() {
200        let dir = TempDir::new().unwrap();
201        let agents_dir = dir.path().join(".agents");
202        std::fs::create_dir_all(&agents_dir).unwrap();
203
204        add_to_gitignore(&agents_dir).unwrap();
205
206        let content = std::fs::read_to_string(agents_dir.join(".gitignore")).unwrap();
207        assert!(content.contains(".mars/"));
208    }
209
210    #[test]
211    fn add_to_gitignore_idempotent() {
212        let dir = TempDir::new().unwrap();
213        let agents_dir = dir.path().join(".agents");
214        std::fs::create_dir_all(&agents_dir).unwrap();
215
216        add_to_gitignore(&agents_dir).unwrap();
217        add_to_gitignore(&agents_dir).unwrap();
218
219        let content = std::fs::read_to_string(agents_dir.join(".gitignore")).unwrap();
220        assert_eq!(content.matches(".mars/").count(), 1);
221    }
222}