1use crate::cli;
4use crate::config::{Config, ConfigError};
5use crate::generator::{self, GeneratorError};
6use crate::templates::{self, TemplateError};
7use crate::verify::{self, VerifyError, VerifyReport};
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10
11#[derive(Debug)]
13pub struct ApplyResult {
14 pub files_generated: Vec<PathBuf>,
15 pub verification: VerifyReport,
16}
17
18#[derive(Debug, Error)]
20pub enum ApplyError {
21 #[error("Not a Rust crate: Cargo.toml not found in target directory")]
23 NotRustCrate,
24
25 #[error("Not a git repository: .git/ directory not found")]
27 NotGitRepo,
28
29 #[error("Conflicting files detected: {}", .0.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", "))]
31 ConflictingFiles(Vec<PathBuf>),
32
33 #[error("Configuration error: {0}")]
35 ConfigError(#[from] ConfigError),
36
37 #[error("Generator error: {0}")]
39 GeneratorError(#[from] GeneratorError),
40
41 #[error("Verification error: {0}")]
43 VerifyError(#[from] VerifyError),
44
45 #[error("Template error: {0}")]
47 TemplateError(#[from] TemplateError),
48
49 #[error("CLI error: {0}")]
51 CliError(#[from] cli::CliError),
52}
53
54fn derive_project_name(target_dir: &Path) -> String {
60 let cargo_toml = target_dir.join("Cargo.toml");
61 if let Ok(contents) = std::fs::read_to_string(&cargo_toml)
62 && let Ok(value) = contents.parse::<toml::Value>()
63 && let Some(name) = value
64 .get("package")
65 .and_then(|package| package.get("name"))
66 .and_then(|name| name.as_str())
67 {
68 return name.to_string();
69 }
70
71 target_dir
72 .file_name()
73 .map(|name| name.to_string_lossy().into_owned())
74 .unwrap_or_else(|| "project".to_string())
75}
76
77pub fn apply_init(target_dir: &Path, force: bool) -> Result<ApplyResult, ApplyError> {
94 let cargo_toml = target_dir.join("Cargo.toml");
95 if !cargo_toml.exists() {
96 return Err(ApplyError::NotRustCrate);
97 }
98
99 let git_dir = target_dir.join(".git");
100 if !git_dir.exists() {
101 return Err(ApplyError::NotGitRepo);
102 }
103
104 let conflicts = generator::check_conflicts(target_dir);
105 if !conflicts.is_empty() {
106 if !force {
107 return Err(ApplyError::ConflictingFiles(conflicts));
108 }
109 eprintln!(
110 "Warning: Overwriting {} existing file(s) due to --force flag",
111 conflicts.len()
112 );
113 }
114
115 let test_timeout = cli::prompt_test_timeout()?;
116
117 let config = Config {
118 rust_bucket_version: env!("CARGO_PKG_VERSION").to_string(),
119 test_timeout,
120 project_name: derive_project_name(target_dir),
121 };
122
123 let config_path = target_dir.join("rust-bucket.toml");
124 config.save(&config_path)?;
125
126 let (_temp_dir, temp_path) = templates::extract_to_temp()?;
127
128 let mut files_generated = generator::render(&temp_path, target_dir, &config, force)?;
129
130 let claude_symlink = generator::create_claude_symlink(target_dir)?;
131 files_generated.push(claude_symlink);
132
133 generator::ensure_gitignore(target_dir)?;
134
135 let seeded = generator::seed_files(&temp_path, target_dir, &config)?;
136 files_generated.extend(seeded);
137
138 let verification = verify::run_all(target_dir)?;
139
140 Ok(ApplyResult {
141 files_generated,
142 verification,
143 })
144}
145
146pub fn apply_update(target_dir: &Path) -> Result<ApplyResult, ApplyError> {
162 let cargo_toml = target_dir.join("Cargo.toml");
163 if !cargo_toml.exists() {
164 return Err(ApplyError::NotRustCrate);
165 }
166
167 let git_dir = target_dir.join(".git");
168 if !git_dir.exists() {
169 return Err(ApplyError::NotGitRepo);
170 }
171
172 let config_path = target_dir.join("rust-bucket.toml");
173 let mut config = Config::load(&config_path)?;
174
175 let current_version = env!("CARGO_PKG_VERSION");
176 if config.rust_bucket_version != current_version {
177 eprintln!(
178 "Note: Config was last generated with rust-bucket v{}, updating to v{}",
179 config.rust_bucket_version, current_version
180 );
181 }
182
183 config.rust_bucket_version = current_version.to_string();
184
185 config.save(&config_path)?;
186
187 let (_temp_dir, temp_path) = templates::extract_to_temp()?;
188
189 let mut files_generated = generator::render(&temp_path, target_dir, &config, true)?;
190
191 let claude_symlink = generator::create_claude_symlink(target_dir)?;
192 files_generated.push(claude_symlink);
193
194 generator::ensure_gitignore(target_dir)?;
195
196 let seeded = generator::seed_files(&temp_path, target_dir, &config)?;
197 files_generated.extend(seeded);
198
199 let verification = verify::run_all(target_dir)?;
200
201 Ok(ApplyResult {
202 files_generated,
203 verification,
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use std::fs;
211 use tempfile::TempDir;
212
213 fn create_test_rust_crate(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
214 fs::write(
216 path.join("Cargo.toml"),
217 r#"[package]
218name = "test-crate"
219version = "0.1.0"
220edition = "2021"
221"#,
222 )?;
223
224 fs::create_dir(path.join(".git"))?;
226
227 let src_dir = path.join("src");
229 fs::create_dir(&src_dir)?;
230 fs::write(src_dir.join("lib.rs"), "// test lib\n")?;
231 Ok(())
232 }
233
234 #[test]
235 fn test_derive_project_name_from_cargo_toml() -> Result<(), Box<dyn std::error::Error>> {
236 let temp_dir = TempDir::new()?;
237 create_test_rust_crate(temp_dir.path())?;
238
239 assert_eq!(derive_project_name(temp_dir.path()), "test-crate");
240 Ok(())
241 }
242
243 #[test]
244 fn test_derive_project_name_falls_back_to_dir_name() -> Result<(), Box<dyn std::error::Error>> {
245 let temp_dir = TempDir::new()?;
246 let workspace_root = temp_dir.path().join("my-workspace");
247 fs::create_dir(&workspace_root)?;
248
249 fs::write(
251 workspace_root.join("Cargo.toml"),
252 "[workspace]\nmembers = [\"crate-a\"]\n",
253 )?;
254
255 assert_eq!(derive_project_name(&workspace_root), "my-workspace");
256 Ok(())
257 }
258
259 #[test]
260 fn test_apply_init_not_rust_crate() -> Result<(), Box<dyn std::error::Error>> {
261 let temp_dir = TempDir::new()?;
262 let result = apply_init(temp_dir.path(), false);
263
264 assert!(result.is_err());
265 assert!(
266 matches!(result.unwrap_err(), ApplyError::NotRustCrate),
267 "Expected NotRustCrate error"
268 );
269 Ok(())
270 }
271
272 #[test]
273 fn test_apply_init_not_git_repo() -> Result<(), Box<dyn std::error::Error>> {
274 let temp_dir = TempDir::new()?;
275
276 fs::write(
278 temp_dir.path().join("Cargo.toml"),
279 "[package]\nname = \"test\"",
280 )?;
281
282 let result = apply_init(temp_dir.path(), false);
283
284 assert!(result.is_err());
285 assert!(
286 matches!(result.unwrap_err(), ApplyError::NotGitRepo),
287 "Expected NotGitRepo error"
288 );
289 Ok(())
290 }
291
292 #[test]
293 fn test_apply_init_conflicts_without_force() -> Result<(), Box<dyn std::error::Error>> {
294 let temp_dir = TempDir::new()?;
295 create_test_rust_crate(temp_dir.path())?;
296
297 fs::write(temp_dir.path().join("AGENTS.md"), "existing content")?;
299
300 let result = apply_init(temp_dir.path(), false);
301
302 assert!(result.is_err());
303 let err = result.unwrap_err();
304 assert!(
305 matches!(&err, ApplyError::ConflictingFiles(_)),
306 "Expected ConflictingFiles error"
307 );
308 if let ApplyError::ConflictingFiles(conflicts) = err {
309 assert!(!conflicts.is_empty());
310 assert!(
311 conflicts
312 .iter()
313 .any(|p| p.file_name().is_some_and(|n| n == "AGENTS.md"))
314 );
315 }
316 Ok(())
317 }
318}