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/// Implements the first-time flow described in ARCHITECTURE.md.
57///
58/// # Arguments
59///
60/// * `target_dir` - The target directory to apply rust-bucket to
61/// * `force` - If true, overwrite existing managed files; if false, fail on conflicts
62///
63/// # Errors
64///
65/// Returns `ApplyError` if:
66/// - The target is not a Rust crate (no Cargo.toml)
67/// - The target is not a git repository (no .git/)
68/// - Conflicting files exist and force is false
69/// - Any step in the process fails (config save, template extraction, rendering, verification)
70pub fn apply_init(target_dir: &Path, force: bool) -> Result<ApplyResult, ApplyError> {
71    let cargo_toml = target_dir.join("Cargo.toml");
72    if !cargo_toml.exists() {
73        return Err(ApplyError::NotRustCrate);
74    }
75
76    let git_dir = target_dir.join(".git");
77    if !git_dir.exists() {
78        return Err(ApplyError::NotGitRepo);
79    }
80
81    let conflicts = generator::check_conflicts(target_dir);
82    if !conflicts.is_empty() {
83        if !force {
84            return Err(ApplyError::ConflictingFiles(conflicts));
85        }
86        eprintln!(
87            "Warning: Overwriting {} existing file(s) due to --force flag",
88            conflicts.len()
89        );
90    }
91
92    let test_timeout = cli::prompt_test_timeout()?;
93
94    let config = Config {
95        rust_bucket_version: env!("CARGO_PKG_VERSION").to_string(),
96        test_timeout,
97        project_name: "Rust-Bucket".to_string(),
98    };
99
100    let config_path = target_dir.join("rust-bucket.toml");
101    config.save(&config_path)?;
102
103    let (_temp_dir, temp_path) = templates::extract_to_temp()?;
104
105    let mut files_generated = generator::render(&temp_path, target_dir, &config, force)?;
106
107    let claude_symlink = generator::create_claude_symlink(target_dir)?;
108    files_generated.push(claude_symlink);
109
110    generator::ensure_gitignore(target_dir)?;
111
112    let seeded = generator::seed_files(&temp_path, target_dir, &config)?;
113    files_generated.extend(seeded);
114
115    let verification = verify::run_all(target_dir)?;
116
117    Ok(ApplyResult {
118        files_generated,
119        verification,
120    })
121}
122
123/// Apply rust-bucket to a target directory in update mode (subsequent runs).
124///
125/// Implements the update flow described in ARCHITECTURE.md.
126///
127/// # Arguments
128///
129/// * `target_dir` - The target directory to update rust-bucket files in
130///
131/// # Errors
132///
133/// Returns `ApplyError` if:
134/// - The target is not a Rust crate (no Cargo.toml)
135/// - The target is not a git repository (no .git/)
136/// - The rust-bucket.toml config file cannot be loaded
137/// - Any step in the process fails (config save, template extraction, rendering, verification)
138pub fn apply_update(target_dir: &Path) -> Result<ApplyResult, ApplyError> {
139    let cargo_toml = target_dir.join("Cargo.toml");
140    if !cargo_toml.exists() {
141        return Err(ApplyError::NotRustCrate);
142    }
143
144    let git_dir = target_dir.join(".git");
145    if !git_dir.exists() {
146        return Err(ApplyError::NotGitRepo);
147    }
148
149    let config_path = target_dir.join("rust-bucket.toml");
150    let mut config = Config::load(&config_path)?;
151
152    let current_version = env!("CARGO_PKG_VERSION");
153    if config.rust_bucket_version != current_version {
154        eprintln!(
155            "Note: Config was last generated with rust-bucket v{}, updating to v{}",
156            config.rust_bucket_version, current_version
157        );
158    }
159
160    config.rust_bucket_version = current_version.to_string();
161
162    config.save(&config_path)?;
163
164    let (_temp_dir, temp_path) = templates::extract_to_temp()?;
165
166    let mut files_generated = generator::render(&temp_path, target_dir, &config, true)?;
167
168    let claude_symlink = generator::create_claude_symlink(target_dir)?;
169    files_generated.push(claude_symlink);
170
171    generator::ensure_gitignore(target_dir)?;
172
173    let seeded = generator::seed_files(&temp_path, target_dir, &config)?;
174    files_generated.extend(seeded);
175
176    let verification = verify::run_all(target_dir)?;
177
178    Ok(ApplyResult {
179        files_generated,
180        verification,
181    })
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::fs;
188    use tempfile::TempDir;
189
190    fn create_test_rust_crate(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
191        // Create Cargo.toml
192        fs::write(
193            path.join("Cargo.toml"),
194            r#"[package]
195name = "test-crate"
196version = "0.1.0"
197edition = "2021"
198"#,
199        )?;
200
201        // Create .git directory
202        fs::create_dir(path.join(".git"))?;
203
204        // Create src directory with lib.rs
205        let src_dir = path.join("src");
206        fs::create_dir(&src_dir)?;
207        fs::write(src_dir.join("lib.rs"), "// test lib\n")?;
208        Ok(())
209    }
210
211    #[test]
212    fn test_apply_init_not_rust_crate() -> Result<(), Box<dyn std::error::Error>> {
213        let temp_dir = TempDir::new()?;
214        let result = apply_init(temp_dir.path(), false);
215
216        assert!(result.is_err());
217        assert!(
218            matches!(result.unwrap_err(), ApplyError::NotRustCrate),
219            "Expected NotRustCrate error"
220        );
221        Ok(())
222    }
223
224    #[test]
225    fn test_apply_init_not_git_repo() -> Result<(), Box<dyn std::error::Error>> {
226        let temp_dir = TempDir::new()?;
227
228        // Create Cargo.toml but not .git
229        fs::write(
230            temp_dir.path().join("Cargo.toml"),
231            "[package]\nname = \"test\"",
232        )?;
233
234        let result = apply_init(temp_dir.path(), false);
235
236        assert!(result.is_err());
237        assert!(
238            matches!(result.unwrap_err(), ApplyError::NotGitRepo),
239            "Expected NotGitRepo error"
240        );
241        Ok(())
242    }
243
244    #[test]
245    fn test_apply_init_conflicts_without_force() -> Result<(), Box<dyn std::error::Error>> {
246        let temp_dir = TempDir::new()?;
247        create_test_rust_crate(temp_dir.path())?;
248
249        // Create a conflicting file
250        fs::write(temp_dir.path().join("AGENTS.md"), "existing content")?;
251
252        let result = apply_init(temp_dir.path(), false);
253
254        assert!(result.is_err());
255        let err = result.unwrap_err();
256        assert!(
257            matches!(&err, ApplyError::ConflictingFiles(_)),
258            "Expected ConflictingFiles error"
259        );
260        if let ApplyError::ConflictingFiles(conflicts) = err {
261            assert!(!conflicts.is_empty());
262            assert!(
263                conflicts
264                    .iter()
265                    .any(|p| p.file_name().is_some_and(|n| n == "AGENTS.md"))
266            );
267        }
268        Ok(())
269    }
270}