1use std::path::Path;
10
11use crate::config::{Config, Settings};
12use crate::error::{ConfigError, MarsError};
13
14use super::output;
15
16#[derive(Debug, clap::Args)]
18pub struct InitArgs {
19 pub target: Option<String>,
21
22 #[arg(long, value_name = "DIR")]
24 pub link: Vec<String>,
25}
26
27fn 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
47pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
49 let managed_root = if let Some(root) = explicit_root {
51 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 let config_path = managed_root.join("mars.toml");
61 let already_initialized = config_path.exists();
62
63 if !already_initialized {
64 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 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 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
119fn 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 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 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 assert!(agents_dir.join("mars.toml").exists());
195 let _ = args; }
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}