use std::path::{Path, PathBuf};
use crate::error::AgentConfigError;
use crate::util::{fs_atomic, md_block};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PlanTarget {
Hook {
tag: String,
},
Mcp {
name: String,
},
Skill {
name: String,
},
Instruction {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum InstallStatus {
Absent,
InstalledOwned {
owner: String,
},
InstalledOtherOwner {
owner: String,
},
PresentUnowned,
LedgerOnly {
owner: String,
},
Drifted {
issues: Vec<DriftIssue>,
},
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DriftIssue {
LedgerOnly {
path: PathBuf,
owner: Option<String>,
},
ConfigOnly {
path: PathBuf,
},
OwnerMismatch {
expected: String,
actual: Option<String>,
path: Option<PathBuf>,
},
MalformedConfig {
path: PathBuf,
reason: String,
},
MalformedLedger {
path: PathBuf,
reason: String,
},
BackupCollision {
path: PathBuf,
},
MissingBackup {
path: PathBuf,
},
StaleBackup {
path: PathBuf,
},
UnexpectedDirectoryShape {
path: PathBuf,
reason: String,
},
SkillMissingSkillMd {
dir: PathBuf,
missing: PathBuf,
},
SkillAssetEscapesRoot {
path: PathBuf,
root: PathBuf,
},
UnsupportedButPresent {
path: PathBuf,
},
SkillIncomplete {
dir: PathBuf,
missing: PathBuf,
},
InstructionContentDrift {
path: PathBuf,
},
InvalidConfig {
path: PathBuf,
reason: String,
},
MultipleEntries {
name: String,
count: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum StatusWarning {
BackupExists {
path: PathBuf,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PathStatus {
Missing {
path: PathBuf,
},
Exists {
path: PathBuf,
},
Invalid {
path: PathBuf,
reason: String,
},
}
#[must_use]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct StatusReport {
pub target: PlanTarget,
pub status: InstallStatus,
pub config_path: Option<PathBuf>,
pub ledger_path: Option<PathBuf>,
pub files: Vec<PathStatus>,
pub warnings: Vec<StatusWarning>,
}
#[derive(Debug, Clone)]
pub(crate) enum ConfigPresence {
Absent,
Single,
Duplicate {
count: usize,
},
Invalid {
reason: String,
},
}
impl StatusReport {
pub(crate) fn for_mcp(
name: &str,
config_path: PathBuf,
ledger_path: PathBuf,
presence: ConfigPresence,
expected_owner: &str,
recorded_owner: Option<String>,
) -> Self {
let target = PlanTarget::Mcp {
name: name.to_string(),
};
Self::assemble(
target,
Some(config_path),
Some(ledger_path),
presence,
expected_owner,
recorded_owner,
Vec::new(),
)
}
pub(crate) fn for_tagged_hook(
tag: &str,
config_path: PathBuf,
presence: ConfigPresence,
) -> Self {
let target = PlanTarget::Hook {
tag: tag.to_string(),
};
let mut files = Vec::new();
let mut warnings = Vec::new();
let status = match presence {
ConfigPresence::Single => {
files.push(PathStatus::Exists {
path: config_path.clone(),
});
InstallStatus::InstalledOwned {
owner: tag.to_string(),
}
}
ConfigPresence::Duplicate { count } => {
files.push(PathStatus::Exists {
path: config_path.clone(),
});
InstallStatus::Drifted {
issues: vec![DriftIssue::MultipleEntries {
name: tag.to_string(),
count,
}],
}
}
ConfigPresence::Invalid { reason } => {
files.push(PathStatus::Invalid {
path: config_path.clone(),
reason: reason.clone(),
});
InstallStatus::Drifted {
issues: vec![DriftIssue::InvalidConfig {
path: config_path.clone(),
reason,
}],
}
}
ConfigPresence::Absent => {
if config_path.exists() {
files.push(PathStatus::Exists {
path: config_path.clone(),
});
} else {
files.push(PathStatus::Missing {
path: config_path.clone(),
});
}
check_backup(&config_path, &mut warnings);
InstallStatus::Absent
}
};
Self {
target,
status,
config_path: Some(config_path),
ledger_path: None,
files,
warnings,
}
}
pub(crate) fn for_file_hook(tag: &str, file_path: PathBuf) -> Self {
let target = PlanTarget::Hook {
tag: tag.to_string(),
};
let exists = file_path.exists();
let mut files = Vec::new();
let mut warnings = Vec::new();
let status = if exists {
files.push(PathStatus::Exists {
path: file_path.clone(),
});
InstallStatus::InstalledOwned {
owner: tag.to_string(),
}
} else {
files.push(PathStatus::Missing {
path: file_path.clone(),
});
check_backup(&file_path, &mut warnings);
InstallStatus::Absent
};
Self {
target,
status,
config_path: Some(file_path),
ledger_path: None,
files,
warnings,
}
}
pub(crate) fn for_markdown_block_hook(
tag: &str,
file_path: PathBuf,
) -> Result<Self, AgentConfigError> {
let target = PlanTarget::Hook {
tag: tag.to_string(),
};
let exists = file_path.exists();
let mut files = Vec::new();
let mut warnings = Vec::new();
let status = if exists {
let host = fs_atomic::read_to_string_or_empty(&file_path)?;
if md_block::malformed(&host, tag) {
files.push(PathStatus::Invalid {
path: file_path.clone(),
reason: "malformed agent-config markdown fence".into(),
});
InstallStatus::Drifted {
issues: vec![DriftIssue::MalformedConfig {
path: file_path.clone(),
reason: "malformed agent-config markdown fence".into(),
}],
}
} else {
files.push(PathStatus::Exists {
path: file_path.clone(),
});
if md_block::contains(&host, tag) {
InstallStatus::InstalledOwned {
owner: tag.to_string(),
}
} else {
InstallStatus::Absent
}
}
} else {
files.push(PathStatus::Missing {
path: file_path.clone(),
});
check_backup(&file_path, &mut warnings);
InstallStatus::Absent
};
Ok(Self {
target,
status,
config_path: Some(file_path),
ledger_path: None,
files,
warnings,
})
}
pub(crate) fn for_skill(
name: &str,
skill_dir: PathBuf,
manifest_path: PathBuf,
ledger_path: PathBuf,
expected_owner: &str,
recorded_owner: Option<String>,
) -> Self {
let target = PlanTarget::Skill {
name: name.to_string(),
};
let dir_exists = skill_dir.exists();
let manifest_exists = manifest_path.exists();
let mut extra_drift = Vec::new();
let presence = if dir_exists {
if !manifest_exists {
extra_drift.push(DriftIssue::SkillIncomplete {
dir: skill_dir.clone(),
missing: manifest_path.clone(),
});
}
ConfigPresence::Single
} else {
ConfigPresence::Absent
};
let mut report = Self::assemble(
target,
Some(skill_dir.clone()),
Some(ledger_path),
presence,
expected_owner,
recorded_owner,
extra_drift,
);
report.files.clear();
if dir_exists {
report.files.push(PathStatus::Exists {
path: skill_dir.clone(),
});
report.files.push(if manifest_exists {
PathStatus::Exists {
path: manifest_path,
}
} else {
PathStatus::Missing {
path: manifest_path,
}
});
} else {
report.files.push(PathStatus::Missing { path: skill_dir });
report.files.push(PathStatus::Missing {
path: manifest_path,
});
}
report
}
pub(crate) fn for_instruction(
name: &str,
instruction_path: PathBuf,
ledger_path: PathBuf,
presence: ConfigPresence,
expected_owner: &str,
recorded_owner: Option<String>,
) -> Self {
let target = PlanTarget::Instruction {
name: name.to_string(),
};
Self::assemble(
target,
Some(instruction_path),
Some(ledger_path),
presence,
expected_owner,
recorded_owner,
Vec::new(),
)
}
fn assemble(
target: PlanTarget,
config_path: Option<PathBuf>,
ledger_path: Option<PathBuf>,
presence: ConfigPresence,
expected_owner: &str,
recorded_owner: Option<String>,
mut extra_drift: Vec<DriftIssue>,
) -> Self {
let mut files = Vec::new();
let mut warnings = Vec::new();
if let Some(p) = config_path.as_ref() {
files.push(if p.exists() {
PathStatus::Exists { path: p.clone() }
} else {
PathStatus::Missing { path: p.clone() }
});
}
if let Some(p) = ledger_path.as_ref() {
files.push(if p.exists() {
PathStatus::Exists { path: p.clone() }
} else {
PathStatus::Missing { path: p.clone() }
});
}
let mut status = match (&presence, recorded_owner.as_deref()) {
(ConfigPresence::Invalid { reason }, _) => {
if let Some(p) = config_path.as_ref() {
if let Some(slot) = files
.iter_mut()
.find(|f| matches!(f, PathStatus::Exists { path } if path == p))
{
*slot = PathStatus::Invalid {
path: p.clone(),
reason: reason.clone(),
};
}
}
let mut issues = std::mem::take(&mut extra_drift);
issues.push(DriftIssue::InvalidConfig {
path: config_path.clone().unwrap_or_default(),
reason: reason.clone(),
});
InstallStatus::Drifted { issues }
}
(ConfigPresence::Duplicate { count }, _) => {
let target_name = match &target {
PlanTarget::Hook { tag } => tag.clone(),
PlanTarget::Mcp { name }
| PlanTarget::Skill { name }
| PlanTarget::Instruction { name } => name.clone(),
};
let mut issues = std::mem::take(&mut extra_drift);
issues.push(DriftIssue::MultipleEntries {
name: target_name,
count: *count,
});
InstallStatus::Drifted { issues }
}
(ConfigPresence::Single, Some(owner)) if owner == expected_owner => {
InstallStatus::InstalledOwned {
owner: owner.to_string(),
}
}
(ConfigPresence::Single, Some(owner)) => InstallStatus::InstalledOtherOwner {
owner: owner.to_string(),
},
(ConfigPresence::Single, None) => InstallStatus::PresentUnowned,
(ConfigPresence::Absent, Some(owner)) => InstallStatus::LedgerOnly {
owner: owner.to_string(),
},
(ConfigPresence::Absent, None) => InstallStatus::Absent,
};
if !extra_drift.is_empty() {
let mut issues = extra_drift;
if let InstallStatus::Drifted { issues: existing } = &mut status {
std::mem::swap(existing, &mut issues);
existing.extend(issues);
} else {
status = InstallStatus::Drifted { issues };
}
}
if matches!(status, InstallStatus::Absent) {
if let Some(p) = config_path.as_ref() {
check_backup(p, &mut warnings);
}
}
Self {
target,
status,
config_path,
ledger_path,
files,
warnings,
}
}
}
fn check_backup(path: &Path, warnings: &mut Vec<StatusWarning>) {
let mut bak = path.to_path_buf();
let name = bak
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
let mut name = name.into_string().unwrap_or_default();
if name.is_empty() {
return;
}
name.push_str(".bak");
bak.set_file_name(name);
if bak.exists() {
warnings.push(StatusWarning::BackupExists { path: bak });
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn for_mcp_owned_when_owner_matches() {
let dir = tempdir().unwrap();
let cfg = dir.path().join("mcp.json");
let led = dir.path().join(".agent-config-mcp.json");
std::fs::write(&cfg, b"{}").unwrap();
std::fs::write(&led, b"{}").unwrap();
let r = StatusReport::for_mcp(
"github",
cfg.clone(),
led,
ConfigPresence::Single,
"myapp",
Some("myapp".into()),
);
assert!(matches!(
r.status,
InstallStatus::InstalledOwned { ref owner } if owner == "myapp"
));
assert_eq!(
r.target,
PlanTarget::Mcp {
name: "github".into()
}
);
}
#[test]
fn for_mcp_other_owner_when_recorded_differs() {
let dir = tempdir().unwrap();
let cfg = dir.path().join("mcp.json");
let led = dir.path().join(".agent-config-mcp.json");
let r = StatusReport::for_mcp(
"github",
cfg,
led,
ConfigPresence::Single,
"myapp",
Some("otherapp".into()),
);
assert!(matches!(
r.status,
InstallStatus::InstalledOtherOwner { ref owner } if owner == "otherapp"
));
}
#[test]
fn for_mcp_present_unowned_when_no_ledger_record() {
let dir = tempdir().unwrap();
let r = StatusReport::for_mcp(
"github",
dir.path().join("mcp.json"),
dir.path().join("ledger.json"),
ConfigPresence::Single,
"myapp",
None,
);
assert!(matches!(r.status, InstallStatus::PresentUnowned));
}
#[test]
fn for_mcp_ledger_only_when_config_absent() {
let dir = tempdir().unwrap();
let r = StatusReport::for_mcp(
"github",
dir.path().join("mcp.json"),
dir.path().join("ledger.json"),
ConfigPresence::Absent,
"myapp",
Some("myapp".into()),
);
assert!(matches!(
r.status,
InstallStatus::LedgerOnly { ref owner } if owner == "myapp"
));
}
#[test]
fn for_mcp_absent_when_neither_present() {
let dir = tempdir().unwrap();
let r = StatusReport::for_mcp(
"github",
dir.path().join("mcp.json"),
dir.path().join("ledger.json"),
ConfigPresence::Absent,
"myapp",
None,
);
assert!(matches!(r.status, InstallStatus::Absent));
}
#[test]
fn for_mcp_drifted_on_invalid_config() {
let dir = tempdir().unwrap();
let cfg = dir.path().join("mcp.json");
std::fs::write(&cfg, b"{not valid").unwrap();
let r = StatusReport::for_mcp(
"github",
cfg.clone(),
dir.path().join("ledger.json"),
ConfigPresence::Invalid {
reason: "expected `:` at line 1".into(),
},
"myapp",
None,
);
let issues = match &r.status {
InstallStatus::Drifted { issues } => issues,
other => panic!("expected Drifted, got {other:?}"),
};
assert!(matches!(issues[0], DriftIssue::InvalidConfig { .. }));
}
#[test]
fn for_skill_incomplete_when_manifest_missing() {
let dir = tempdir().unwrap();
let skill_dir = dir.path().join("alpha");
std::fs::create_dir_all(&skill_dir).unwrap();
let manifest = skill_dir.join("SKILL.md");
let r = StatusReport::for_skill(
"alpha",
skill_dir,
manifest,
dir.path().join("ledger.json"),
"myapp",
Some("myapp".into()),
);
let issues = match &r.status {
InstallStatus::Drifted { issues } => issues,
other => panic!("expected Drifted, got {other:?}"),
};
assert!(matches!(issues[0], DriftIssue::SkillIncomplete { .. }));
}
#[test]
fn backup_warning_emitted_when_bak_exists() {
let dir = tempdir().unwrap();
let cfg = dir.path().join("mcp.json");
std::fs::write(dir.path().join("mcp.json.bak"), b"{}").unwrap();
let r = StatusReport::for_mcp(
"github",
cfg,
dir.path().join("ledger.json"),
ConfigPresence::Absent,
"myapp",
None,
);
assert!(r
.warnings
.iter()
.any(|w| matches!(w, StatusWarning::BackupExists { .. })));
}
#[test]
fn tagged_hook_owned_when_present() {
let dir = tempdir().unwrap();
let cfg = dir.path().join("settings.json");
std::fs::write(&cfg, b"{}").unwrap();
let r = StatusReport::for_tagged_hook("alpha", cfg, ConfigPresence::Single);
assert!(matches!(
r.status,
InstallStatus::InstalledOwned { ref owner } if owner == "alpha"
));
assert!(r.ledger_path.is_none());
}
#[test]
fn tagged_hook_drifted_on_invalid_config() {
let dir = tempdir().unwrap();
let r = StatusReport::for_tagged_hook(
"alpha",
dir.path().join("settings.json"),
ConfigPresence::Invalid {
reason: "broken".into(),
},
);
assert!(matches!(r.status, InstallStatus::Drifted { .. }));
}
#[test]
fn file_hook_present_when_path_exists() {
let dir = tempdir().unwrap();
let p = dir.path().join("alpha.md");
std::fs::write(&p, b"x").unwrap();
let r = StatusReport::for_file_hook("alpha", p);
assert!(matches!(r.status, InstallStatus::InstalledOwned { .. }));
}
#[test]
fn file_hook_absent_when_missing() {
let dir = tempdir().unwrap();
let r = StatusReport::for_file_hook("alpha", dir.path().join("alpha.md"));
assert!(matches!(r.status, InstallStatus::Absent));
}
}