agpm_cli/installer/
cleanup.rs

1//! Cleanup utilities for removing obsolete artifacts.
2
3use crate::lockfile::LockFile;
4use anyhow::{Context, Result};
5
6/// Removes artifacts that are no longer needed based on lockfile comparison.
7///
8/// This function performs automatic cleanup of obsolete resource files by comparing
9/// the old and new lockfiles. It identifies and removes artifacts that have been:
10/// - **Removed from manifest**: Dependencies deleted from `agpm.toml`
11/// - **Changed to content-only**: Dependencies that changed from `install: true` to `install: false`
12/// - **Relocated**: Files with changed `installed_at` paths due to:
13///   - Relative path preservation (v0.3.18+)
14///   - Custom target changes
15///   - Dependency name changes
16/// - **Replaced**: Resources that moved due to source or version changes
17///
18/// After removing files, it also cleans up any empty parent directories to prevent
19/// directory accumulation over time.
20///
21/// # Cleanup Strategy
22///
23/// The function uses a **set-based difference algorithm**:
24/// 1. Collects all `installed_at` paths from the new lockfile into a `HashSet`
25///    (excluding resources with `install: false` which should not have files)
26/// 2. Iterates through old lockfile resources
27/// 3. For each old path not in the new set:
28///    - Removes the file if it exists
29///    - Recursively cleans empty parent directories
30///    - Records the path for reporting
31///
32/// # Arguments
33///
34/// * `old_lockfile` - The previous lockfile state containing old installation paths
35/// * `new_lockfile` - The current lockfile state with updated installation paths
36/// * `project_dir` - The project root directory (usually contains `.claude/`)
37///
38/// # Returns
39///
40/// Returns `Ok(Vec<String>)` containing the list of `installed_at` paths that were
41/// successfully removed. An empty vector indicates no artifacts needed cleanup.
42///
43/// # Errors
44///
45/// Returns an error if:
46/// - File removal fails due to permissions or locks
47/// - Directory cleanup encounters unexpected I/O errors
48/// - File system operations fail for other reasons
49///
50/// # Examples
51///
52/// ## Basic Cleanup After Update
53///
54/// ```no_run
55/// use agpm_cli::installer::cleanup_removed_artifacts;
56/// use agpm_cli::lockfile::LockFile;
57/// use std::path::Path;
58///
59/// # async fn example() -> anyhow::Result<()> {
60/// let old_lockfile = LockFile::load(Path::new("agpm.lock"))?;
61/// let new_lockfile = LockFile::new(); // After resolution
62/// let project_dir = Path::new(".");
63///
64/// let removed = cleanup_removed_artifacts(&old_lockfile, &new_lockfile, project_dir).await?;
65/// if !removed.is_empty() {
66///     println!("Cleaned up {} artifact(s)", removed.len());
67///     for path in removed {
68///         println!("  - Removed: {}", path);
69///     }
70/// }
71/// # Ok(())
72/// # }
73/// ```
74///
75/// ## Cleanup After Path Migration
76///
77/// When relative path preservation changes installation paths:
78///
79/// ```text
80/// Old lockfile (v0.3.17):
81///   installed_at: ".claude/agents/helper.md"
82///
83/// New lockfile (v0.3.18+):
84///   installed_at: ".claude/agents/ai/helper.md"  # Preserved subdirectory
85///
86/// Cleanup removes: .claude/agents/helper.md
87/// ```
88///
89/// ## Cleanup After Dependency Removal
90///
91/// ```no_run
92/// # use agpm_cli::installer::cleanup_removed_artifacts;
93/// # use agpm_cli::lockfile::{LockFile, LockedResource};
94/// # use std::path::Path;
95/// # async fn removal_example() -> anyhow::Result<()> {
96/// // Old lockfile had 3 agents
97/// let mut old_lockfile = LockFile::new();
98/// old_lockfile.agents = vec![
99///     // ... 3 agents including one at .claude/agents/removed.md
100/// ];
101///
102/// // New lockfile only has 2 agents (one was removed from manifest)
103/// let mut new_lockfile = LockFile::new();
104/// new_lockfile.agents = vec![
105///     // ... 2 agents, removed.md is gone
106/// ];
107///
108/// let removed = cleanup_removed_artifacts(&old_lockfile, &new_lockfile, Path::new(".")).await?;
109/// assert!(removed.contains(&".claude/agents/removed.md".to_string()));
110/// # Ok(())
111/// # }
112/// ```
113///
114/// ## Integration with Install Command
115///
116/// This function is automatically called during `agpm install` when both old and
117/// new lockfiles exist:
118///
119/// ```rust,ignore
120/// // In src/cli/install.rs
121/// if !self.frozen && !self.regenerate && lockfile_path.exists() {
122///     if let Ok(old_lockfile) = LockFile::load(&lockfile_path) {
123///         detect_tag_movement(&old_lockfile, &lockfile, self.quiet);
124///
125///         // Automatic cleanup of removed or moved artifacts
126///         if let Ok(removed) = cleanup_removed_artifacts(
127///             &old_lockfile,
128///             &lockfile,
129///             actual_project_dir,
130///         ).await && !removed.is_empty() && !self.quiet {
131///             println!("🗑️  Cleaned up {} moved or removed artifact(s)", removed.len());
132///         }
133///     }
134/// }
135/// ```
136///
137/// # Performance
138///
139/// - **Time Complexity**: O(n + m) where n = old resources, m = new resources
140/// - **Space Complexity**: O(m) for the `HashSet` of new paths
141/// - **I/O Operations**: One file removal per obsolete artifact
142/// - **Directory Cleanup**: Walks up parent directories once per removed file
143///
144/// The function is highly efficient as it:
145/// - Uses `HashSet` for O(1) path lookups
146/// - Only performs I/O for files that actually exist
147/// - Cleans directories recursively but stops at first non-empty directory
148///
149/// # Safety
150///
151/// - Only removes files explicitly tracked in the old lockfile
152/// - Never removes files outside the project directory
153/// - Stops directory cleanup at `.claude/` boundary
154/// - Handles concurrent file access gracefully (ENOENT is not an error)
155///
156/// # Use Cases
157///
158/// ## Relative Path Migration (v0.3.18+)
159///
160/// When upgrading to v0.3.18+, resource paths change to preserve directory structure:
161/// ```text
162/// Before: .claude/agents/helper.md  (flat)
163/// After:  .claude/agents/ai/helper.md  (nested)
164/// ```
165/// This function removes the old flat file automatically.
166///
167/// ## Dependency Reorganization
168///
169/// When reorganizing dependencies with custom targets:
170/// ```toml
171/// # Before
172/// [agents]
173/// helper = { source = "community", path = "agents/helper.md" }
174///
175/// # After (with custom target)
176/// [agents]
177/// helper = { source = "community", path = "agents/helper.md", target = "tools" }
178/// ```
179/// Old file at `.claude/agents/helper.md` is removed, new file at
180/// `.claude/agents/tools/helper.md` is installed.
181///
182/// ## Manifest Cleanup
183///
184/// Simply removing dependencies from `agpm.toml` triggers automatic cleanup:
185/// ```toml
186/// # Remove unwanted dependency
187/// [agents]
188/// # old-agent = { ... }  # Commented out or deleted
189/// ```
190/// The next `agpm install` removes the old agent file automatically.
191///
192/// # Version History
193///
194/// - **v0.3.18**: Introduced to handle relative path preservation and custom target changes
195/// - Works in conjunction with `cleanup_empty_dirs()` for comprehensive cleanup
196pub async fn cleanup_removed_artifacts(
197    old_lockfile: &LockFile,
198    new_lockfile: &LockFile,
199    project_dir: &std::path::Path,
200) -> Result<Vec<String>> {
201    use std::collections::HashSet;
202
203    let mut removed = Vec::new();
204
205    // Collect installed paths from new lockfile (only resources that should have files on disk)
206    // Resources with install=false are content-only and should not have files
207    let new_paths: HashSet<String> = new_lockfile
208        .all_resources()
209        .into_iter()
210        .filter(|r| r.install != Some(false))
211        .map(|r| r.installed_at.clone())
212        .collect();
213
214    // Check each old resource
215    for old_resource in old_lockfile.all_resources() {
216        // If the old path doesn't exist in new lockfile, it needs to be removed
217        if !new_paths.contains(&old_resource.installed_at) {
218            let full_path = project_dir.join(&old_resource.installed_at);
219
220            tracing::debug!(
221                "Cleanup: old path not in new lockfile - name={}, path={}, install={:?}, exists={}",
222                old_resource.name,
223                old_resource.installed_at,
224                old_resource.install,
225                full_path.exists()
226            );
227
228            // Only remove if the file actually exists
229            if full_path.exists() {
230                tokio::fs::remove_file(&full_path).await.with_context(|| {
231                    format!("Failed to remove old artifact: {}", full_path.display())
232                })?;
233
234                removed.push(old_resource.installed_at.clone());
235
236                // Try to clean up empty parent directories
237                cleanup_empty_dirs(&full_path).await?;
238            }
239        }
240    }
241
242    Ok(removed)
243}
244
245/// Recursively removes empty parent directories up to the project root.
246///
247/// This helper function performs bottom-up directory cleanup after file removal.
248/// It walks up the directory tree from a given file path, removing empty parent
249/// directories until it encounters:
250/// - A non-empty directory (containing other files or subdirectories)
251/// - The `.claude` directory boundary (cleanup stops here for safety)
252/// - The project root (no parent directory)
253/// - A directory that cannot be removed (permissions, locks, etc.)
254///
255/// This prevents accumulation of empty directory trees over time as resources
256/// are removed, renamed, or relocated.
257///
258/// # Cleanup Algorithm
259///
260/// The function implements a **safe recursive cleanup** strategy:
261/// 1. Starts at the parent directory of the given file path
262/// 2. Attempts to remove the directory
263/// 3. If successful (directory was empty), moves to parent and repeats
264/// 4. If unsuccessful, stops immediately (directory has content or other issues)
265/// 5. Always stops at `.claude/` directory to avoid over-cleanup
266///
267/// # Safety Boundaries
268///
269/// The function enforces strict boundaries to prevent accidental data loss:
270/// - **`.claude/` boundary**: Never removes the `.claude` directory itself
271/// - **Project root**: Stops if parent directory is None
272/// - **Non-empty guard**: Only removes truly empty directories
273/// - **Error tolerance**: ENOENT (directory not found) is not considered an error
274///
275/// # Arguments
276///
277/// * `file_path` - The path to the removed file whose parent directories should be cleaned.
278///   Typically this is the full path to a resource file that was just deleted.
279///
280/// # Returns
281///
282/// Returns `Ok(())` in all normal cases, including:
283/// - All empty directories successfully removed
284/// - Cleanup stopped at a non-empty directory
285/// - Directory already doesn't exist (ENOENT)
286///
287/// # Errors
288///
289/// Returns an error only for unexpected I/O failures during directory removal
290/// that are not normal "directory not empty" or "not found" errors.
291///
292/// # Examples
293///
294/// ## Basic Directory Cleanup
295///
296/// ```ignore
297/// # use agpm_cli::installer::cleanup_empty_dirs;
298/// # use std::path::Path;
299/// # use std::fs;
300/// # async fn example() -> anyhow::Result<()> {
301/// // After removing: .claude/agents/rust/specialized/expert.md
302/// let file_path = Path::new(".claude/agents/rust/specialized/expert.md");
303///
304/// // If this was the last file in specialized/, the directory will be removed
305/// // If specialized/ was the last item in rust/, that will be removed too
306/// // Cleanup stops at .claude/agents/ or when it finds a non-empty directory
307/// cleanup_empty_dirs(file_path).await?;
308/// # Ok(())
309/// # }
310/// ```
311///
312/// ## Cleanup Scenarios
313///
314/// ### Scenario 1: Full Cleanup
315///
316/// ```text
317/// Before:
318///   .claude/agents/rust/specialized/expert.md  (only file in hierarchy)
319///
320/// After removing expert.md:
321///   cleanup_empty_dirs() removes:
322///   - .claude/agents/rust/specialized/  (now empty)
323///   - .claude/agents/rust/              (now empty)
324///   Stops at .claude/agents/ (keeps base directory)
325/// ```
326///
327/// ### Scenario 2: Partial Cleanup
328///
329/// ```text
330/// Before:
331///   .claude/agents/rust/specialized/expert.md
332///   .claude/agents/rust/specialized/tester.md
333///   .claude/agents/rust/basic.md
334///
335/// After removing expert.md:
336///   .claude/agents/rust/specialized/ still has tester.md
337///   cleanup_empty_dirs() stops at specialized/ (not empty)
338/// ```
339///
340/// ### Scenario 3: Boundary Enforcement
341///
342/// ```text
343/// After removing: .claude/agents/only-agent.md
344///
345/// cleanup_empty_dirs() attempts to remove:
346/// - .claude/agents/ (empty now)
347/// - But stops because parent is .claude/ (boundary)
348///
349/// Result: .claude/agents/ remains (empty but preserved)
350/// ```
351///
352/// ## Integration with `cleanup_removed_artifacts`
353///
354/// This function is called automatically by [`cleanup_removed_artifacts`]
355/// after each file removal:
356///
357/// ```rust,ignore
358/// for old_resource in old_lockfile.all_resources() {
359///     if !new_paths.contains(&old_resource.installed_at) {
360///         let full_path = project_dir.join(&old_resource.installed_at);
361///
362///         if full_path.exists() {
363///             tokio::fs::remove_file(&full_path).await?;
364///             removed.push(old_resource.installed_at.clone());
365///
366///             // Automatic directory cleanup after file removal
367///             cleanup_empty_dirs(&full_path).await?;
368///         }
369///     }
370/// }
371/// ```
372///
373/// # Performance
374///
375/// - **Time Complexity**: O(d) where d = directory depth from file to `.claude/`
376/// - **I/O Operations**: One `remove_dir` attempt per directory level
377/// - **Early Termination**: Stops immediately on first non-empty directory
378///
379/// The function is extremely efficient as it:
380/// - Only walks up the directory tree (no scanning of siblings)
381/// - Stops at the first non-empty directory (no unnecessary attempts)
382/// - Uses atomic `remove_dir` which fails fast on non-empty directories
383/// - Typical depth is 2-4 levels (.claude/agents/subdir/file.md)
384///
385/// # Error Handling Strategy
386///
387/// The function differentiates between expected and unexpected errors:
388///
389/// | Error Kind | Interpretation | Action |
390/// |------------|----------------|--------|
391/// | `Ok(())` | Directory was empty and removed | Continue up tree |
392/// | `ENOENT` | Directory doesn't exist | Continue up tree (race condition) |
393/// | `ENOTEMPTY` | Directory has contents | Stop cleanup (expected) |
394/// | `EPERM` | No permission | Stop cleanup (expected) |
395/// | Other | Unexpected I/O error | Propagate error |
396///
397/// In practice, most errors simply stop the cleanup process without failing
398/// the overall operation, as the goal is best-effort cleanup.
399///
400/// # Thread Safety
401///
402/// This function is safe for concurrent use because:
403/// - Uses async filesystem operations from `tokio::fs`
404/// - `remove_dir` is atomic (succeeds only if directory is empty)
405/// - ENOENT handling accounts for race conditions
406/// - Multiple concurrent calls won't interfere with each other
407///
408/// # Use Cases
409///
410/// ## After Pattern-Based Installation Changes
411///
412/// When pattern matches change, old directory structures may become empty:
413/// ```toml
414/// # Old: pattern matched agents/rust/expert.md, agents/rust/testing.md
415/// # New: pattern only matches agents/rust/expert.md
416///
417/// # testing.md removed → agents/rust/ might now be empty
418/// ```
419///
420/// ## After Custom Target Changes
421///
422/// Custom target changes can leave old directory structures empty:
423/// ```toml
424/// # Old: target = "tools"  → .claude/agents/tools/helper.md
425/// # New: target = "utils" → .claude/agents/utils/helper.md
426///
427/// # .claude/agents/tools/ might now be empty
428/// ```
429///
430/// ## After Dependency Removal
431///
432/// Removing the last dependency in a category may leave empty subdirectories:
433/// ```toml
434/// [agents]
435/// # Removed: python-helper (was in agents/python/)
436/// # Only agents/rust/ remains
437///
438/// # .claude/agents/python/ should be cleaned up
439/// ```
440///
441/// # Design Rationale
442///
443/// This function exists to solve the "directory accumulation problem":
444/// - Without cleanup: Empty directories accumulate over time
445/// - With cleanup: Project structure stays clean and organized
446/// - Safety boundaries: Prevents accidental removal of important directories
447/// - Best-effort approach: Cleanup failures don't block main operations
448///
449/// # Version History
450///
451/// - **v0.3.18**: Introduced alongside [`cleanup_removed_artifacts`]
452/// - Complements relative path preservation by cleaning up old directory structures
453async fn cleanup_empty_dirs(file_path: &std::path::Path) -> Result<()> {
454    let mut current = file_path.parent();
455
456    while let Some(dir) = current {
457        // Stop if we've reached .claude or the project root
458        if dir.ends_with(".claude") || dir.parent().is_none() {
459            break;
460        }
461
462        // Try to remove the directory (will only succeed if empty)
463        match tokio::fs::remove_dir(dir).await {
464            Ok(()) => {
465                // Directory was empty and removed, continue up
466                current = dir.parent();
467            }
468            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
469                // Directory doesn't exist, continue up
470                current = dir.parent();
471            }
472            Err(_) => {
473                // Directory is not empty or we don't have permission, stop here
474                break;
475            }
476        }
477    }
478
479    Ok(())
480}