1use std::path::Path;
10
11use crate::error::{ConfigError, MarsError};
12
13use super::output;
14
15#[derive(Debug, clap::Args)]
17pub struct InitArgs {
18 pub target: Option<String>,
20
21 #[arg(long, value_name = "DIR")]
23 pub link: Vec<String>,
24}
25
26fn 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
56pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
58 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 let target = if let Some(t) = args.target.as_deref() {
65 t.to_string()
66 } else {
67 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 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 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 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
129fn 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 }
143 Err(e) => return Err(e),
144 }
145 Ok(())
146}
147
148fn ensure_local_gitignored(project_root: &Path) -> Result<(), MarsError> {
150 ensure_gitignore_entry(project_root, "mars.local.toml")
151}
152
153fn 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}