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/// Derive the project name for a target repository.
55///
56/// Prefers the `[package].name` declared in the target's `Cargo.toml`. Falls
57/// back to the target directory's file name when the manifest has no package
58/// name (e.g. a virtual workspace root) or cannot be parsed.
59fn 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
77/// Apply rust-bucket to a target directory for the first time.
78///
79/// Implements the first-time flow described in ARCHITECTURE.md.
80///
81/// # Arguments
82///
83/// * `target_dir` - The target directory to apply rust-bucket to
84/// * `force` - If true, overwrite existing managed files; if false, fail on conflicts
85///
86/// # Errors
87///
88/// Returns `ApplyError` if:
89/// - The target is not a Rust crate (no Cargo.toml)
90/// - The target is not a git repository (no .git/)
91/// - Conflicting files exist and force is false
92/// - Any step in the process fails (config save, template extraction, rendering, verification)
93pub 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
146/// Apply rust-bucket to a target directory in update mode (subsequent runs).
147///
148/// Implements the update flow described in ARCHITECTURE.md.
149///
150/// # Arguments
151///
152/// * `target_dir` - The target directory to update rust-bucket files in
153///
154/// # Errors
155///
156/// Returns `ApplyError` if:
157/// - The target is not a Rust crate (no Cargo.toml)
158/// - The target is not a git repository (no .git/)
159/// - The rust-bucket.toml config file cannot be loaded
160/// - Any step in the process fails (config save, template extraction, rendering, verification)
161pub 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        // Create Cargo.toml
215        fs::write(
216            path.join("Cargo.toml"),
217            r#"[package]
218name = "test-crate"
219version = "0.1.0"
220edition = "2021"
221"#,
222        )?;
223
224        // Create .git directory
225        fs::create_dir(path.join(".git"))?;
226
227        // Create src directory with lib.rs
228        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        // Workspace manifest without a [package] section.
250        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        // Create Cargo.toml but not .git
277        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        // Create a conflicting file
298        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}