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!("{} already initialized", managed_root.display()));
88 }
89 }
90
91 if !args.link.is_empty() {
93 let ctx = super::MarsContext::new(managed_root.clone())?;
94 for link_target in &args.link {
95 let link_args = super::link::LinkArgs {
96 target: link_target.clone(),
97 unlink: false,
98 force: false,
99 };
100 super::link::run(&link_args, &ctx, json)?;
101 }
102 }
103
104 if json {
105 output::print_json(&serde_json::json!({
106 "ok": true,
107 "path": managed_root.to_string_lossy(),
108 "already_initialized": already_initialized,
109 "links": args.link,
110 }));
111 }
112
113 Ok(0)
114}
115
116fn add_to_gitignore(agents_dir: &Path) -> Result<(), MarsError> {
118 let gitignore_path = agents_dir.join(".gitignore");
119 let entry = ".mars/";
120
121 if gitignore_path.exists() {
122 let content = std::fs::read_to_string(&gitignore_path)?;
123 if content.lines().any(|line| line.trim() == entry) {
124 return Ok(());
125 }
126 let mut new_content = content;
128 if !new_content.ends_with('\n') && !new_content.is_empty() {
129 new_content.push('\n');
130 }
131 new_content.push_str(entry);
132 new_content.push('\n');
133 crate::fs::atomic_write(&gitignore_path, new_content.as_bytes())?;
134 } else {
135 crate::fs::atomic_write(&gitignore_path, format!("{entry}\n").as_bytes())?;
136 }
137
138 Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use tempfile::TempDir;
145
146 #[test]
147 fn validate_target_accepts_simple_names() {
148 assert!(validate_target(".agents").is_ok());
149 assert!(validate_target(".claude").is_ok());
150 assert!(validate_target("my-agents").is_ok());
151 }
152
153 #[test]
154 fn validate_target_rejects_paths() {
155 assert!(validate_target("./foo").is_err());
156 assert!(validate_target("foo/bar").is_err());
157 assert!(validate_target("/absolute/path").is_err());
158 }
159
160 #[test]
161 fn validate_target_rejects_dots() {
162 assert!(validate_target(".").is_err());
163 assert!(validate_target("..").is_err());
164 }
165
166 #[test]
167 fn validate_target_rejects_empty() {
168 assert!(validate_target("").is_err());
169 }
170
171 #[test]
172 fn init_creates_agents_toml() {
173 let dir = TempDir::new().unwrap();
174 let agents_dir = dir.path().join(".agents");
175
176 let args = InitArgs {
177 target: None,
178 link: vec![],
179 };
180
181 std::fs::create_dir_all(&agents_dir).unwrap();
184 let config = Config {
185 sources: indexmap::IndexMap::new(),
186 settings: Settings::default(),
187 };
188 crate::config::save(&agents_dir, &config).unwrap();
189
190 assert!(agents_dir.join("mars.toml").exists());
192 let _ = args; }
194
195 #[test]
196 fn add_to_gitignore_creates_file() {
197 let dir = TempDir::new().unwrap();
198 let agents_dir = dir.path().join(".agents");
199 std::fs::create_dir_all(&agents_dir).unwrap();
200
201 add_to_gitignore(&agents_dir).unwrap();
202
203 let content = std::fs::read_to_string(agents_dir.join(".gitignore")).unwrap();
204 assert!(content.contains(".mars/"));
205 }
206
207 #[test]
208 fn add_to_gitignore_idempotent() {
209 let dir = TempDir::new().unwrap();
210 let agents_dir = dir.path().join(".agents");
211 std::fs::create_dir_all(&agents_dir).unwrap();
212
213 add_to_gitignore(&agents_dir).unwrap();
214 add_to_gitignore(&agents_dir).unwrap();
215
216 let content = std::fs::read_to_string(agents_dir.join(".gitignore")).unwrap();
217 assert_eq!(content.matches(".mars/").count(), 1);
218 }
219}