rust_bucket/apply.rs
1// Apply command implementation for first-time and subsequent runs
2
3use 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/// Result of applying rust-bucket to a target directory
12#[derive(Debug)]
13pub struct ApplyResult {
14 pub files_generated: Vec<PathBuf>,
15 pub verification: VerifyReport,
16}
17
18/// Errors that can occur during the apply operation
19#[derive(Debug, Error)]
20pub enum ApplyError {
21 /// Target directory is not a Rust crate (no Cargo.toml found)
22 #[error("Not a Rust crate: Cargo.toml not found in target directory")]
23 NotRustCrate,
24
25 /// Target directory is not a git repository (no .git/ found)
26 #[error("Not a git repository: .git/ directory not found")]
27 NotGitRepo,
28
29 /// Conflicting files exist in the target directory
30 #[error("Conflicting files detected: {}", .0.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", "))]
31 ConflictingFiles(Vec<PathBuf>),
32
33 /// Configuration-related error
34 #[error("Configuration error: {0}")]
35 ConfigError(#[from] ConfigError),
36
37 /// Template generation error
38 #[error("Generator error: {0}")]
39 GeneratorError(#[from] GeneratorError),
40
41 /// Verification error
42 #[error("Verification error: {0}")]
43 VerifyError(#[from] VerifyError),
44
45 /// Template extraction error
46 #[error("Template error: {0}")]
47 TemplateError(#[from] TemplateError),
48
49 /// CLI interaction error
50 #[error("CLI error: {0}")]
51 CliError(#[from] cli::CliError),
52}
53
54/// Apply rust-bucket to a target directory for the first time.
55///
56/// This function implements the first-time flow as specified in ARCHITECTURE.md:
57/// 1. Assert Cargo.toml exists (this is a Rust crate)
58/// 2. Assert .git/ exists (git init was done)
59/// 3. Check for conflicts using generator::check_conflicts()
60/// - If conflicts exist and !force, fail with list of conflicts
61/// - If conflicts exist and force, warn and continue
62/// 4. Prompt for choices (test_timeout) using cli::prompt_test_timeout()
63/// 5. Create Config with choices
64/// 6. Write rust-bucket.toml using config.save()
65/// 7. Extract templates to temp dir using templates::extract_to_temp()
66/// 8. Render templates to target dir using generator::render()
67/// 9. Run verification using verify::run_all()
68/// 10. Return result
69///
70/// # Arguments
71///
72/// * `target_dir` - The target directory to apply rust-bucket to
73/// * `force` - If true, overwrite existing managed files; if false, fail on conflicts
74///
75/// # Errors
76///
77/// Returns `ApplyError` if:
78/// - The target is not a Rust crate (no Cargo.toml)
79/// - The target is not a git repository (no .git/)
80/// - Conflicting files exist and force is false
81/// - Any step in the process fails (config save, template extraction, rendering, verification)
82pub fn apply_init(target_dir: &Path, force: bool) -> Result<ApplyResult, ApplyError> {
83 // Step 1: Assert Cargo.toml exists
84 let cargo_toml = target_dir.join("Cargo.toml");
85 if !cargo_toml.exists() {
86 return Err(ApplyError::NotRustCrate);
87 }
88
89 // Step 2: Assert .git/ exists
90 let git_dir = target_dir.join(".git");
91 if !git_dir.exists() {
92 return Err(ApplyError::NotGitRepo);
93 }
94
95 // Step 3: Check for conflicts
96 let conflicts = generator::check_conflicts(target_dir);
97 if !conflicts.is_empty() {
98 if !force {
99 return Err(ApplyError::ConflictingFiles(conflicts));
100 }
101 // If force is true, we'll continue and overwrite
102 eprintln!(
103 "Warning: Overwriting {} existing file(s) due to --force flag",
104 conflicts.len()
105 );
106 }
107
108 // Step 4: Prompt for choices
109 let test_timeout = cli::prompt_test_timeout()?;
110
111 // Step 5: Create Config with choices
112 let config = Config {
113 rust_bucket_version: env!("CARGO_PKG_VERSION").to_string(),
114 test_timeout,
115 project_name: "Rust-Bucket".to_string(),
116 };
117
118 // Step 6: Write rust-bucket.toml
119 let config_path = target_dir.join("rust-bucket.toml");
120 config.save(&config_path)?;
121
122 // Step 7: Extract templates to temp dir
123 let (_temp_dir, temp_path) = templates::extract_to_temp()?;
124
125 // Step 8: Render templates to target dir
126 // When force is true, we use overwrite=true to replace existing files
127 let mut files_generated = generator::render(&temp_path, target_dir, &config, force)?;
128
129 // Step 8b: Create CLAUDE.md symlink to AGENTS.md
130 let claude_symlink = generator::create_claude_symlink(target_dir)?;
131 files_generated.push(claude_symlink);
132
133 // Step 9: Run verification
134 let verification = verify::run_all(target_dir)?;
135
136 // Step 10: Return result
137 Ok(ApplyResult {
138 files_generated,
139 verification,
140 })
141}
142
143/// Apply rust-bucket to a target directory in update mode (subsequent runs).
144///
145/// This function implements the subsequent-times flow as specified in ARCHITECTURE.md:
146/// 1. Assert Cargo.toml exists (this is a Rust crate)
147/// 2. Assert .git/ exists (git init was done)
148/// 3. Load rust-bucket.toml using Config::load()
149/// 4. Validate config (log/warn if version is different)
150/// 5. Pre-populate choices from config (test_timeout already set)
151/// 6. Prompt for any NEW choices not in config (none in v1, so skip)
152/// 7. Update config's rust_bucket_version to current version
153/// 8. Write updated rust-bucket.toml using config.save()
154/// 9. Extract templates to temp dir using templates::extract_to_temp()
155/// 10. Render templates to target dir using generator::render() with overwrite=true
156/// 11. Run verification using verify::run_all()
157/// 12. Return result
158///
159/// # Arguments
160///
161/// * `target_dir` - The target directory to update rust-bucket files in
162///
163/// # Errors
164///
165/// Returns `ApplyError` if:
166/// - The target is not a Rust crate (no Cargo.toml)
167/// - The target is not a git repository (no .git/)
168/// - The rust-bucket.toml config file cannot be loaded
169/// - Any step in the process fails (config save, template extraction, rendering, verification)
170pub fn apply_update(target_dir: &Path) -> Result<ApplyResult, ApplyError> {
171 // Step 1: Assert Cargo.toml exists
172 let cargo_toml = target_dir.join("Cargo.toml");
173 if !cargo_toml.exists() {
174 return Err(ApplyError::NotRustCrate);
175 }
176
177 // Step 2: Assert .git/ exists
178 let git_dir = target_dir.join(".git");
179 if !git_dir.exists() {
180 return Err(ApplyError::NotGitRepo);
181 }
182
183 // Step 3: Load rust-bucket.toml
184 let config_path = target_dir.join("rust-bucket.toml");
185 let mut config = Config::load(&config_path)?;
186
187 // Step 4: Validate config - warn if version is different
188 let current_version = env!("CARGO_PKG_VERSION");
189 if config.rust_bucket_version != current_version {
190 eprintln!(
191 "Note: Config was last generated with rust-bucket v{}, updating to v{}",
192 config.rust_bucket_version, current_version
193 );
194 }
195
196 // Step 5: Pre-populate choices from config (test_timeout is already set)
197 // The config already contains test_timeout from previous run
198
199 // Step 6: Prompt for any NEW choices not in config
200 // In v1, there are no new choices to prompt for, so we skip this step
201
202 // Step 7: Update config's rust_bucket_version to current version
203 config.rust_bucket_version = current_version.to_string();
204
205 // Step 8: Write updated rust-bucket.toml
206 config.save(&config_path)?;
207
208 // Step 9: Extract templates to temp dir
209 let (_temp_dir, temp_path) = templates::extract_to_temp()?;
210
211 // Step 10: Render templates to target dir with overwrite=true
212 // Note: The update flow is simpler than init - no conflict checking needed since overwrite=true
213 let mut files_generated = generator::render(&temp_path, target_dir, &config, true)?;
214
215 // Step 10b: Create CLAUDE.md symlink to AGENTS.md
216 let claude_symlink = generator::create_claude_symlink(target_dir)?;
217 files_generated.push(claude_symlink);
218
219 // Step 11: Run verification
220 let verification = verify::run_all(target_dir)?;
221
222 // Step 12: Return result
223 Ok(ApplyResult {
224 files_generated,
225 verification,
226 })
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::fs;
233 use tempfile::TempDir;
234
235 fn create_test_rust_crate(path: &Path) {
236 // Create Cargo.toml
237 fs::write(
238 path.join("Cargo.toml"),
239 r#"[package]
240name = "test-crate"
241version = "0.1.0"
242edition = "2021"
243"#,
244 )
245 .unwrap();
246
247 // Create .git directory
248 fs::create_dir(path.join(".git")).unwrap();
249
250 // Create src directory with lib.rs
251 let src_dir = path.join("src");
252 fs::create_dir(&src_dir).unwrap();
253 fs::write(src_dir.join("lib.rs"), "// test lib\n").unwrap();
254 }
255
256 #[test]
257 fn test_apply_init_not_rust_crate() {
258 let temp_dir = TempDir::new().unwrap();
259 let result = apply_init(temp_dir.path(), false);
260
261 assert!(result.is_err());
262 assert!(
263 matches!(result.unwrap_err(), ApplyError::NotRustCrate),
264 "Expected NotRustCrate error"
265 );
266 }
267
268 #[test]
269 fn test_apply_init_not_git_repo() {
270 let temp_dir = TempDir::new().unwrap();
271
272 // Create Cargo.toml but not .git
273 fs::write(
274 temp_dir.path().join("Cargo.toml"),
275 "[package]\nname = \"test\"",
276 )
277 .unwrap();
278
279 let result = apply_init(temp_dir.path(), false);
280
281 assert!(result.is_err());
282 assert!(
283 matches!(result.unwrap_err(), ApplyError::NotGitRepo),
284 "Expected NotGitRepo error"
285 );
286 }
287
288 #[test]
289 fn test_apply_init_conflicts_without_force() {
290 let temp_dir = TempDir::new().unwrap();
291 create_test_rust_crate(temp_dir.path());
292
293 // Create a conflicting file
294 fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
295
296 let result = apply_init(temp_dir.path(), false);
297
298 assert!(result.is_err());
299 let err = result.unwrap_err();
300 assert!(
301 matches!(&err, ApplyError::ConflictingFiles(_)),
302 "Expected ConflictingFiles error"
303 );
304 if let ApplyError::ConflictingFiles(conflicts) = err {
305 assert!(!conflicts.is_empty());
306 assert!(
307 conflicts
308 .iter()
309 .any(|p| p.file_name().unwrap() == "AGENTS.md")
310 );
311 }
312 }
313}