provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use super::install_decision::{classify_install, InstallVerdict, TargetEntry, TargetState};
use super::{file_report, FileInstallReport, FileStatus, InstallReport};
use crate::atomic_file::{ensure_managed_directory, FileRollbackJournal, FileSnapshot};
use provenance_macros::rule;
use std::path::{Path, PathBuf};

mod auxiliary;
use auxiliary::{plan_agents_cleanup, RemoveAction};
mod claude;
use claude::ClaudeAction;

pub(super) struct InstallPlan {
    base: PathBuf,
    global: bool,
    canonical_dir: PathBuf,
    claude_dir: PathBuf,
    canonical: Vec<FileAction>,
    claude: Vec<ClaudeAction>,
    legacy: Vec<RemoveAction>,
    agents: Option<FileAction>,
    link_mode: &'static str,
    fallback_reason: Option<String>,
}

#[derive(Clone, Copy)]
pub(super) enum InstallRequest {
    Init,
    Standalone {
        global: bool,
        force: bool,
        copy: bool,
    },
}

impl InstallPlan {
    pub(super) fn build(base: &Path, request: InstallRequest) -> anyhow::Result<Self> {
        let (global, force, copy, cleanup_agents) = match request {
            InstallRequest::Init => (false, false, false, false),
            InstallRequest::Standalone {
                global,
                force,
                copy,
            } => (global, force, copy, true),
        };
        let canonical_dir = base.join(".agents/skills");
        let claude_dir = base.join(".claude/skills");
        ensure_managed_directory(base, &canonical_dir)?;
        ensure_managed_directory(base, &claude_dir)?;
        let mut canonical = Vec::new();
        for skill in super::EMBEDDED_SKILLS {
            canonical.push(FileAction::managed(
                canonical_dir.join(skill.directory).join("SKILL.md"),
                super::render::skill_file(skill).into_bytes(),
                force,
            )?);
        }

        let mut claude = Vec::new();
        let mut link_mode = if copy { "copy" } else { "symlink" };
        let mut fallback_reason = None;
        for skill in super::EMBEDDED_SKILLS {
            let action = ClaudeAction::plan(skill, &canonical_dir, &claude_dir, force, copy)?;
            if action.uses_copy_fallback() && !copy {
                link_mode = "copy-fallback";
                fallback_reason.get_or_insert_with(|| action.fallback_reason());
            }
            claude.push(action);
        }

        let mut legacy = Vec::new();
        for path in crate::legacy_cleanup::skill_paths(base) {
            if let Some(action) = RemoveAction::plan(path)? {
                legacy.push(action);
            }
        }
        let agents = cleanup_agents
            .then(|| plan_agents_cleanup(base, global))
            .transpose()?
            .flatten();
        Ok(Self {
            base: base.to_path_buf(),
            global,
            canonical_dir,
            claude_dir,
            canonical,
            claude,
            legacy,
            agents,
            link_mode,
            fallback_reason,
        })
    }

    pub(super) fn apply(self) -> anyhow::Result<InstallReport> {
        let mut rollback = FileRollbackJournal::within(&self.base);
        match self.apply_in(&mut rollback) {
            Ok(report) => {
                rollback.commit()?;
                Ok(report)
            }
            Err(error) => match rollback.rollback() {
                Ok(()) => Err(error),
                Err(rollback) => Err(error.context(format!("skill rollback failed: {rollback:#}"))),
            },
        }
    }

    #[rule("rule_init_installs_bundled_skills")]
    pub(super) fn apply_in(
        mut self,
        rollback: &mut FileRollbackJournal,
    ) -> anyhow::Result<InstallReport> {
        ensure_managed_directory(&self.base, &self.canonical_dir)?;
        ensure_managed_directory(&self.base, &self.claude_dir)?;
        let mut files = Vec::new();
        for action in self.canonical {
            files.push(action.apply(rollback)?);
        }
        for action in self.claude {
            let (reports, runtime_fallback) = action.apply(rollback)?;
            if let Some(reason) = runtime_fallback {
                self.link_mode = "copy-fallback";
                self.fallback_reason.get_or_insert(reason);
            }
            files.extend(reports);
        }
        for action in self.legacy {
            files.push(action.apply(rollback)?);
        }
        if let Some(action) = self.agents {
            files.push(action.apply(rollback)?);
        }
        Ok(InstallReport {
            global: self.global,
            status: super::combined_status(&files),
            canonical_dir: self.canonical_dir.display().to_string(),
            claude_dir: self.claude_dir.display().to_string(),
            link_mode: self.link_mode,
            fallback_reason: self.fallback_reason,
            files,
        })
    }

    pub(super) fn recheck(&self) -> anyhow::Result<()> {
        for action in &self.canonical {
            action.recheck()?;
        }
        for action in &self.claude {
            action.recheck()?;
        }
        for action in &self.legacy {
            action.recheck()?;
        }
        if let Some(action) = &self.agents {
            action.recheck()?;
        }
        Ok(())
    }
}

pub(super) struct FileAction {
    path: PathBuf,
    before: FileSnapshot,
    contents: Vec<u8>,
    status: FileStatus,
    observed: bool,
}

impl FileAction {
    #[rule("rule_init_upgrades_hash_owned_skills")]
    pub(super) fn managed(path: PathBuf, contents: Vec<u8>, force: bool) -> anyhow::Result<Self> {
        let entry = TargetEntry::read(&path)?;
        let before = match entry {
            TargetEntry::Vacant => FileSnapshot::Missing,
            TargetEntry::Other => FileSnapshot::read(&path)?,
            _ => {
                let verdict = classify_install(TargetState::Foreign, force);
                if verdict == InstallVerdict::Refuse {
                    anyhow::bail!(
                        "{} exists and differs; rerun with --force to overwrite",
                        path.display()
                    );
                }
                anyhow::bail!("{} is not a regular file", path.display());
            }
        };
        let unchanged = before.bytes() == Some(contents.as_slice());
        let state = match before.bytes() {
            None => TargetState::Clear,
            Some(existing) if super::ownership::may_replace(existing, &contents) => {
                TargetState::Ours
            }
            Some(_) => TargetState::Foreign,
        };
        let verdict = classify_install(state, force);
        if verdict == InstallVerdict::Refuse {
            anyhow::bail!(
                "{} exists and differs; rerun with --force to overwrite",
                path.display()
            );
        }
        let status = if unchanged {
            FileStatus::Unchanged
        } else if matches!(before, FileSnapshot::Missing) {
            FileStatus::Installed
        } else {
            FileStatus::Updated
        };
        Ok(Self {
            path,
            before,
            contents,
            status,
            observed: true,
        })
    }

    pub(super) const fn exact(
        path: PathBuf,
        before: FileSnapshot,
        contents: Vec<u8>,
        status: FileStatus,
        observed: bool,
    ) -> Self {
        Self {
            path,
            before,
            contents,
            status,
            observed,
        }
    }

    fn recheck(&self) -> anyhow::Result<()> {
        if self.observed {
            self.before.recheck(&self.path)?;
        }
        Ok(())
    }

    fn apply(self, rollback: &mut FileRollbackJournal) -> anyhow::Result<FileInstallReport> {
        if self.status != FileStatus::Unchanged {
            rollback.replace(&self.path, &self.before, &self.contents)?;
        }
        Ok(file_report(&self.path, self.status))
    }
}

#[cfg(test)]
mod tests;