use std::path::PathBuf;
use serde::Serialize;
use crate::{
harness::{Scope, State},
integration::Item,
};
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Action {
Created,
Rewrote,
Updated,
Removed,
Kept,
}
impl Action {
pub fn as_str(self) -> &'static str {
match self {
Action::Created => "created",
Action::Rewrote => "rewrote",
Action::Updated => "updated",
Action::Removed => "removed",
Action::Kept => "kept",
}
}
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct PartResult {
pub part: String,
pub state: State,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "Action"))]
pub action: Option<Action>,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub in_place: bool,
}
impl PartResult {
pub fn wrote(&self) -> bool {
matches!(
self.action,
Some(Action::Created | Action::Rewrote | Action::Updated)
)
}
pub fn changed(&self) -> bool {
self.wrote() || self.action == Some(Action::Removed)
}
pub fn verb(&self) -> &'static str {
self.action
.map_or_else(|| self.state.as_str(), Action::as_str)
}
pub fn to_line(&self) -> String {
if self.in_place {
return format!("{:<7} {} ({}, in place)", self.verb(), self.path, self.part);
}
match (&self.by, self.action) {
(Some(by), Some(Action::Kept)) => {
format!(
"{:<7} {} ({}, used by {by})",
self.verb(),
self.path,
self.part
)
}
(Some(by), _) => format!("{:<7} {} ({}, by {by})", self.verb(), self.path, self.part),
(None, _) => format!("{:<7} {} ({})", self.verb(), self.path, self.part),
}
}
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct HarnessResult {
pub harness: String,
pub parts: Vec<PartResult>,
pub unsupported: Vec<Item>,
pub notes: Vec<String>,
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct InstallResult {
pub scope: Scope,
pub root: PathBuf,
pub harnesses: Vec<HarnessResult>,
pub warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing: Vec<MissingSource>,
}
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct MissingSource {
pub item: Item,
pub path: String,
}
impl InstallResult {
#[cfg(feature = "schemars")]
pub fn schema() -> serde_json::Value {
let mut s = serde_json::to_value(schemars::schema_for!(InstallResult))
.expect("a schema serialises");
s["title"] = "AHA install result".into();
s
}
pub fn harness(&self, id: &str) -> Option<&HarnessResult> {
self.harnesses.iter().find(|h| h.harness == id)
}
pub fn to_text(&self) -> String {
let mut out = String::new();
for h in &self.harnesses {
out.push_str(&format!("{}:\n", h.harness));
for p in &h.parts {
out.push_str(&format!(" {}\n", p.to_line()));
}
if !h.unsupported.is_empty() {
let items: Vec<&str> = h.unsupported.iter().map(|i| i.as_str()).collect();
out.push_str(&format!(" unsupported: {}\n", items.join(", ")));
}
for n in &h.notes {
out.push_str(&format!(" note: {n}\n"));
}
}
for w in &self.warnings {
out.push_str(&format!("warning: {w}\n"));
}
for m in &self.missing {
out.push_str(&format!("missing source: {} ({})\n", m.path, m.item));
}
out
}
pub fn in_sync(&self) -> bool {
self.harnesses
.iter()
.flat_map(|h| &h.parts)
.all(|p| matches!(p.state, State::Current | State::Shared | State::Skipped))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn part(name: &str, state: State, action: Option<Action>, path: &str) -> PartResult {
PartResult {
part: name.into(),
state,
action,
path: path.into(),
by: None,
in_place: false,
}
}
#[test]
fn text_and_json_carry_the_same_content() {
let r = InstallResult {
missing: Vec::new(),
scope: Scope::Project,
root: "/r".into(),
harnesses: vec![
HarnessResult {
harness: "claude".into(),
parts: vec![
part(
"hooks",
State::Absent,
Some(Action::Created),
".claude/settings.json",
),
part("instructions", State::Edited, None, "CLAUDE.md"),
],
unsupported: vec![],
notes: vec![],
},
HarnessResult {
harness: "codex".into(),
parts: vec![PartResult {
by: Some("pi".into()),
..part("instructions", State::Shared, None, "AGENTS.md")
}],
unsupported: vec![Item::Permissions, Item::Commands],
notes: vec!["approve the hooks in /hooks".into()],
},
],
warnings: vec!["copilot may load the instructions twice: CLAUDE.md, AGENTS.md".into()],
};
assert_eq!(
r.to_text(),
"claude:\n created .claude/settings.json (hooks)\n edited CLAUDE.md (instructions)\ncodex:\n shared AGENTS.md (instructions, by pi)\n unsupported: permissions, commands\n note: approve the hooks in /hooks\nwarning: copilot may load the instructions twice: CLAUDE.md, AGENTS.md\n"
);
assert_eq!(
serde_json::to_value(&r).unwrap(),
serde_json::json!({
"scope": "project",
"root": "/r",
"harnesses": [
{
"harness": "claude",
"parts": [
{"part": "hooks", "state": "absent", "action": "created", "path": ".claude/settings.json"},
{"part": "instructions", "state": "edited", "path": "CLAUDE.md"}
],
"unsupported": [],
"notes": []
},
{
"harness": "codex",
"parts": [{"part": "instructions", "state": "shared", "path": "AGENTS.md", "by": "pi"}],
"unsupported": ["permissions", "commands"],
"notes": ["approve the hooks in /hooks"]
}
],
"warnings": ["copilot may load the instructions twice: CLAUDE.md, AGENTS.md"]
})
);
assert_eq!(r.harness("codex").unwrap().parts.len(), 1);
assert!(r.harness("pi").is_none());
}
#[cfg(feature = "schemars")]
#[test]
fn the_schema_describes_the_json() {
let schema = InstallResult::schema();
assert_eq!(schema["title"], "AHA install result");
let part = &schema["$defs"]["PartResult"];
let required: Vec<_> = part["required"].as_array().unwrap().iter().collect();
assert!(required.contains(&&serde_json::json!("state")));
assert!(!required.contains(&&serde_json::json!("action")));
assert!(!required.contains(&&serde_json::json!("by")));
assert!(
!part["properties"]["action"].to_string().contains("null"),
"{part}"
);
assert!(
!part["properties"]["by"].to_string().contains("null"),
"{part}"
);
let text = schema.to_string();
assert!(
!text.contains("[`"),
"rustdoc links leak into the schema: {text}"
);
let state = schema["$defs"]["State"].to_string();
for word in ["skipped", "shared", "absent", "current", "stale", "edited"] {
assert!(state.contains(&format!("\"{word}\"")), "{state}");
}
}
}