Skip to main content

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 8c: Ensure .gitignore contains required entries
134    generator::ensure_gitignore(target_dir)?;
135
136    // Step 8d: Seed STYLE_GUIDE.md if it doesn't exist
137    generator::seed_style_guide(target_dir)?;
138
139    // Step 9: Run verification
140    let verification = verify::run_all(target_dir)?;
141
142    // Step 10: Return result
143    Ok(ApplyResult {
144        files_generated,
145        verification,
146    })
147}
148
149/// Apply rust-bucket to a target directory in update mode (subsequent runs).
150///
151/// This function implements the subsequent-times flow as specified in ARCHITECTURE.md:
152/// 1. Assert Cargo.toml exists (this is a Rust crate)
153/// 2. Assert .git/ exists (git init was done)
154/// 3. Load rust-bucket.toml using Config::load()
155/// 4. Validate config (log/warn if version is different)
156/// 5. Pre-populate choices from config (test_timeout already set)
157/// 6. Prompt for any NEW choices not in config (none in v1, so skip)
158/// 7. Update config's rust_bucket_version to current version
159/// 8. Write updated rust-bucket.toml using config.save()
160/// 9. Extract templates to temp dir using templates::extract_to_temp()
161/// 10. Render templates to target dir using generator::render() with overwrite=true
162/// 11. Run verification using verify::run_all()
163/// 12. Return result
164///
165/// # Arguments
166///
167/// * `target_dir` - The target directory to update rust-bucket files in
168///
169/// # Errors
170///
171/// Returns `ApplyError` if:
172/// - The target is not a Rust crate (no Cargo.toml)
173/// - The target is not a git repository (no .git/)
174/// - The rust-bucket.toml config file cannot be loaded
175/// - Any step in the process fails (config save, template extraction, rendering, verification)
176pub fn apply_update(target_dir: &Path) -> Result<ApplyResult, ApplyError> {
177    // Step 1: Assert Cargo.toml exists
178    let cargo_toml = target_dir.join("Cargo.toml");
179    if !cargo_toml.exists() {
180        return Err(ApplyError::NotRustCrate);
181    }
182
183    // Step 2: Assert .git/ exists
184    let git_dir = target_dir.join(".git");
185    if !git_dir.exists() {
186        return Err(ApplyError::NotGitRepo);
187    }
188
189    // Step 3: Load rust-bucket.toml
190    let config_path = target_dir.join("rust-bucket.toml");
191    let mut config = Config::load(&config_path)?;
192
193    // Step 4: Validate config - warn if version is different
194    let current_version = env!("CARGO_PKG_VERSION");
195    if config.rust_bucket_version != current_version {
196        eprintln!(
197            "Note: Config was last generated with rust-bucket v{}, updating to v{}",
198            config.rust_bucket_version, current_version
199        );
200    }
201
202    // Step 5: Pre-populate choices from config (test_timeout is already set)
203    // The config already contains test_timeout from previous run
204
205    // Step 6: Prompt for any NEW choices not in config
206    // In v1, there are no new choices to prompt for, so we skip this step
207
208    // Step 7: Update config's rust_bucket_version to current version
209    config.rust_bucket_version = current_version.to_string();
210
211    // Step 8: Write updated rust-bucket.toml
212    config.save(&config_path)?;
213
214    // Step 9: Extract templates to temp dir
215    let (_temp_dir, temp_path) = templates::extract_to_temp()?;
216
217    // Step 10: Render templates to target dir with overwrite=true
218    // Note: The update flow is simpler than init - no conflict checking needed since overwrite=true
219    let mut files_generated = generator::render(&temp_path, target_dir, &config, true)?;
220
221    // Step 10b: Create CLAUDE.md symlink to AGENTS.md
222    let claude_symlink = generator::create_claude_symlink(target_dir)?;
223    files_generated.push(claude_symlink);
224
225    // Step 10c: Ensure .gitignore contains required entries
226    generator::ensure_gitignore(target_dir)?;
227
228    // Step 10d: Seed STYLE_GUIDE.md if it doesn't exist
229    generator::seed_style_guide(target_dir)?;
230
231    // Step 11: Run verification
232    let verification = verify::run_all(target_dir)?;
233
234    // Step 12: Return result
235    Ok(ApplyResult {
236        files_generated,
237        verification,
238    })
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use std::fs;
245    use tempfile::TempDir;
246
247    fn create_test_rust_crate(path: &Path) {
248        // Create Cargo.toml
249        fs::write(
250            path.join("Cargo.toml"),
251            r#"[package]
252name = "test-crate"
253version = "0.1.0"
254edition = "2021"
255"#,
256        )
257        .unwrap();
258
259        // Create .git directory
260        fs::create_dir(path.join(".git")).unwrap();
261
262        // Create src directory with lib.rs
263        let src_dir = path.join("src");
264        fs::create_dir(&src_dir).unwrap();
265        fs::write(src_dir.join("lib.rs"), "// test lib\n").unwrap();
266    }
267
268    #[test]
269    fn test_apply_init_not_rust_crate() {
270        let temp_dir = TempDir::new().unwrap();
271        let result = apply_init(temp_dir.path(), false);
272
273        assert!(result.is_err());
274        assert!(
275            matches!(result.unwrap_err(), ApplyError::NotRustCrate),
276            "Expected NotRustCrate error"
277        );
278    }
279
280    #[test]
281    fn test_apply_init_not_git_repo() {
282        let temp_dir = TempDir::new().unwrap();
283
284        // Create Cargo.toml but not .git
285        fs::write(
286            temp_dir.path().join("Cargo.toml"),
287            "[package]\nname = \"test\"",
288        )
289        .unwrap();
290
291        let result = apply_init(temp_dir.path(), false);
292
293        assert!(result.is_err());
294        assert!(
295            matches!(result.unwrap_err(), ApplyError::NotGitRepo),
296            "Expected NotGitRepo error"
297        );
298    }
299
300    #[test]
301    fn test_apply_init_conflicts_without_force() {
302        let temp_dir = TempDir::new().unwrap();
303        create_test_rust_crate(temp_dir.path());
304
305        // Create a conflicting file
306        fs::write(temp_dir.path().join("AGENTS.md"), "existing content").unwrap();
307
308        let result = apply_init(temp_dir.path(), false);
309
310        assert!(result.is_err());
311        let err = result.unwrap_err();
312        assert!(
313            matches!(&err, ApplyError::ConflictingFiles(_)),
314            "Expected ConflictingFiles error"
315        );
316        if let ApplyError::ConflictingFiles(conflicts) = err {
317            assert!(!conflicts.is_empty());
318            assert!(
319                conflicts
320                    .iter()
321                    .any(|p| p.file_name().unwrap() == "AGENTS.md")
322            );
323        }
324    }
325}