provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use super::FileAction;
use crate::atomic_file::{FileRollbackJournal, FileSnapshot};
use crate::skills::install_decision::{classify_install, TargetEntry, TargetState};
use crate::skills::{file_report, skill_name, EmbeddedSkill, FileInstallReport, FileStatus};
use std::path::{Path, PathBuf};

pub(super) fn plan_copy_files(
    skill: &'static EmbeddedSkill,
    canonical_dir: &Path,
    destination: &Path,
    force: bool,
    prospective_missing: bool,
) -> anyhow::Result<Vec<FileAction>> {
    let name = skill_name(skill)?;
    let mut sources = vec![(
        PathBuf::from("SKILL.md"),
        crate::skills::render::skill_file(skill).into_bytes(),
    )];
    crate::skills::copy_tree::collect_sidecars(
        &canonical_dir.join(name),
        Path::new(""),
        &mut sources,
    )?;
    sources
        .into_iter()
        .map(|(relative, contents)| {
            let path = destination.join(relative);
            if prospective_missing {
                if let Err(error) = TargetEntry::read(&path) {
                    let blocked_by_planned_parent_replacement = error
                        .downcast_ref::<std::io::Error>()
                        .is_some_and(|error| error.kind() == std::io::ErrorKind::NotADirectory);
                    if !blocked_by_planned_parent_replacement {
                        return Err(error);
                    }
                }
                let _ = classify_install(TargetState::Clear, force);
                return Ok(FileAction::exact(
                    path,
                    FileSnapshot::Missing,
                    contents,
                    FileStatus::Installed,
                    false,
                ));
            }
            FileAction::managed(path, contents, force)
        })
        .collect()
}

pub(super) struct RemoveAction {
    pub(super) path: PathBuf,
    pub(super) before: FileSnapshot,
    parent: PathBuf,
    parent_entries: Vec<std::ffi::OsString>,
}

impl RemoveAction {
    pub(super) fn plan(path: PathBuf) -> anyhow::Result<Option<Self>> {
        let entry = TargetEntry::read(&path)?;
        let state = match entry {
            TargetEntry::Vacant => TargetState::Clear,
            TargetEntry::Other => {
                let before = FileSnapshot::read(&path)?;
                let managed = before
                    .bytes()
                    .and_then(|bytes| std::str::from_utf8(bytes).ok())
                    .is_some_and(crate::legacy_cleanup::valid_managed_skill);
                let state = if managed {
                    TargetState::Ours
                } else {
                    TargetState::Foreign
                };
                let _ = classify_install(state, false);
                let parent = path
                    .parent()
                    .unwrap_or_else(|| Path::new("."))
                    .to_path_buf();
                let parent_entries = directory_entries(&parent)?;
                return Ok(managed.then_some(Self {
                    path,
                    before,
                    parent,
                    parent_entries,
                }));
            }
            TargetEntry::Directory | TargetEntry::Symlink(_) => TargetState::Foreign,
        };
        let _ = classify_install(state, false);
        Ok(None)
    }

    pub(super) fn apply(
        self,
        rollback: &mut FileRollbackJournal,
    ) -> anyhow::Result<FileInstallReport> {
        self.recheck()?;
        rollback.remove(&self.path, &self.before)?;
        if self.parent_entries.len() == 1 {
            std::fs::remove_dir(&self.parent)?;
        }
        Ok(file_report(&self.path, FileStatus::Removed))
    }

    pub(super) fn recheck(&self) -> anyhow::Result<()> {
        self.before.recheck(&self.path)?;
        anyhow::ensure!(
            directory_entries(&self.parent)? == self.parent_entries,
            "{} changed after skill cleanup was planned; retry",
            self.parent.display()
        );
        Ok(())
    }
}

fn directory_entries(path: &Path) -> anyhow::Result<Vec<std::ffi::OsString>> {
    let mut entries = std::fs::read_dir(path)?
        .map(|entry| entry.map(|entry| entry.file_name()))
        .collect::<Result<Vec<_>, _>>()?;
    entries.sort();
    Ok(entries)
}

pub(super) fn plan_agents_cleanup(base: &Path, global: bool) -> anyhow::Result<Option<FileAction>> {
    let path = crate::legacy_cleanup::agents_path(base, global);
    let _ = TargetEntry::read(&path)?;
    let before = FileSnapshot::read(&path)?;
    let Some(existing) = before.bytes() else {
        let _ = classify_install(TargetState::Clear, false);
        return Ok(None);
    };
    let updated = crate::legacy_cleanup::project_agents(existing);
    let changed = updated != existing;
    let _ = classify_install(
        if changed {
            TargetState::Ours
        } else {
            TargetState::Clear
        },
        false,
    );
    Ok(changed.then(|| FileAction::exact(path, before, updated, FileStatus::Updated, true)))
}