use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{
Error, Result,
fs::read_text,
harness::{
Context, Harness, Markers, Part, Profile, Reads, Scope, State, Tool,
file::render_files,
merge::render_merge,
part::Kind,
record::{Record, read_record, render_record},
result::{Action, HarnessResult, InstallResult, MissingSource, PartResult},
shared::{Candidate, Role, choose},
state::{Observed, expected, hash, observe, state},
write::{Plan, apply_plan},
},
integration::{Integration, Item},
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct InstallOptions {
pub harnesses: Vec<String>,
pub scope: Scope,
pub without: Option<Vec<String>>,
pub force: bool,
}
impl InstallOptions {
pub fn new<I: IntoIterator<Item = S>, S: Into<String>>(harnesses: I, scope: Scope) -> Self {
InstallOptions {
harnesses: harnesses.into_iter().map(Into::into).collect(),
scope,
without: None,
force: false,
}
}
#[must_use]
pub fn without<I: IntoIterator<Item = S>, S: Into<String>>(mut self, parts: I) -> Self {
self.without = Some(parts.into_iter().map(Into::into).collect());
self
}
#[must_use]
pub fn force(mut self, force: bool) -> Self {
self.force = force;
self
}
}
pub(super) struct Member {
pub(super) harness: Arc<dyn Harness>,
pub(super) named: bool,
pub(super) profile: Profile,
pub(super) reads: BTreeMap<Item, Reads>,
pub(super) unsupported: Vec<Item>,
pub(super) declined: Vec<String>,
pub(super) missed: Vec<crate::hook::Event>,
}
pub(super) struct Examined<'a> {
pub(super) member: usize,
pub(super) part: &'a Part,
pub(super) path: String,
pub(super) state: State,
pub(super) existed: bool,
pub(super) by: Option<String>,
pub(super) in_place: bool,
}
#[derive(Debug, Clone)]
pub(super) struct Covered {
pub(super) location: String,
pub(super) by: Option<String>,
}
pub(super) struct Run {
pub(super) cx: Context,
pub(super) markers: Markers,
pub(super) record_path: PathBuf,
pub(super) record: Record,
pub(super) members: Vec<Member>,
pub(super) warnings: Vec<String>,
pub(super) sources: BTreeMap<Item, String>,
pub(super) missing: Vec<MissingSource>,
}
fn display(root: &Path, path: &Path) -> String {
let shown = path.strip_prefix(root).unwrap_or(path);
shown.to_string_lossy().replace('\\', "/")
}
fn profile(
harness: &dyn Harness,
integration: &Integration,
cx: &Context,
) -> Result<(Profile, Vec<Item>)> {
let mut parts = harness.render(integration, cx)?;
let mut rendered = Vec::new();
for p in &parts {
let item = p.name().parse::<Item>().map_err(|()| {
Error::Internal(format!(
"harness `{}` rendered a part named `{}`, not an item",
harness.id(),
p.name()
))
})?;
if rendered.contains(&item) {
return Err(Error::Internal(format!(
"harness `{}` rendered `{item}` twice",
harness.id()
)));
}
rendered.push(item);
}
for raw in integration.parts_for(harness.id()) {
if parts.iter().any(|p| p.name() == raw.name()) {
return Err(Error::Internal(format!(
"the part `{}` of `{}` clashes with another part's name",
raw.name(),
harness.id()
)));
}
if let Ok(item) = raw.name().parse::<Item>() {
rendered.push(item);
}
parts.push(raw.clone());
}
let unsupported = integration
.items()
.into_iter()
.filter(|i| !rendered.contains(i))
.collect();
Ok((Profile::new(parts), unsupported))
}
pub(super) fn run<T: Tool + ?Sized>(
tool: &T,
named: &[String],
scope: Scope,
without: Option<&[String]>,
) -> Result<Run> {
let supported = tool.harnesses();
for id in named {
let h = supported
.iter()
.find(|h| h.id() == id)
.ok_or_else(|| Error::UnknownHarness {
harness: id.clone(),
})?;
if !h.scopes().contains(&scope) {
return Err(Error::UnsupportedScope {
harness: id.clone(),
scope,
});
}
}
let root = tool.root(scope)?;
let record_path = tool.record_path(scope)?;
let record = read_record(&record_path, &display(&root, &record_path))?;
let user_root = match scope {
Scope::User => Some(root.clone()),
_ => tool.root(Scope::User).ok(),
};
let cx = Context::new(tool.name(), scope, root, user_root);
let declared = tool.integration(scope);
let integration = declared.resolve(&cx);
let sources: BTreeMap<Item, String> = Item::ALL
.into_iter()
.filter_map(|i| Some((i, cx.relative(&declared.source(i)?.path)?)))
.collect();
let missing = declared
.missing()
.iter()
.map(|m| MissingSource {
item: m.item,
path: cx
.relative(&m.path)
.unwrap_or_else(|| m.path.display().to_string()),
})
.collect();
let store = tool.declined_store(scope)?;
let mut members = Vec::new();
for h in supported {
let is_named = named.iter().any(|n| n == h.id());
let recorded = record.harnesses.contains_key(h.id());
if !(is_named || recorded) || !h.scopes().contains(&scope) {
continue;
}
let (profile, unsupported) = profile(h.as_ref(), &integration, &cx)?;
let mut missed: Vec<crate::hook::Event> = Vec::new();
if profile.part(Item::Hooks.as_str()).is_some() {
for e in integration.hooks().iter().map(|hk| hk.event()) {
if !h.hook_events().contains(&e) && !missed.contains(&e) {
missed.push(e);
}
}
}
let stored = store.declined(h.id())?;
let declined = match without {
Some(w) if is_named => w
.iter()
.filter(|p| profile.part(p).is_some())
.cloned()
.collect(),
_ => stored,
};
let mut reads = BTreeMap::new();
for p in profile.parts() {
if let Ok(item) = p.name().parse::<Item>() {
reads.insert(item, h.reads(item, &cx)?);
}
}
members.push(Member {
harness: h,
named: is_named,
profile,
reads,
unsupported,
declined,
missed,
});
}
if let Some(part) = without.into_iter().flatten().find(|p| {
!members
.iter()
.any(|m| m.named && m.profile.part(p).is_some())
}) {
return Err(Error::UnknownPart {
harness: named.join(", "),
part: part.clone(),
});
}
Ok(Run {
markers: Markers::new(tool.name()),
cx,
record_path,
record,
members,
warnings: integration.warnings().to_vec(),
sources,
missing,
})
}
impl Run {
fn recorded(&self, member: usize, part: &Part) -> Option<&str> {
let own = |m: &Member| {
self.record
.harnesses
.get(m.harness.id())
.and_then(|t| t.get(part.name()))
.map(String::as_str)
};
own(&self.members[member]).or_else(|| {
part.name().parse::<Item>().ok()?;
self.members.iter().find_map(|m| {
let p = m.profile.part(part.name())?;
(p.location() == part.location()).then(|| own(m)).flatten()
})
})
}
pub(super) fn share(&mut self) -> Result<BTreeMap<(usize, String), Covered>> {
let mut shared = BTreeMap::new();
for item in Item::ALL {
let mut idx = Vec::new();
let mut cands = Vec::new();
for (i, m) in self.members.iter().enumerate() {
let Some(part) = m.profile.part(item.as_str()) else {
continue;
};
if m.declined.iter().any(|d| d == item.as_str()) {
continue;
}
idx.push(i);
cands.push(Candidate {
harness: m.harness.id(),
named: m.named,
part,
reads: &m.reads[&item],
});
}
let choice = choose(
item.as_str(),
&cands,
self.sources.get(&item).map(String::as_str),
)?;
self.warnings.extend(choice.warnings);
for (k, role) in choice.roles.into_iter().enumerate() {
if let Role::InPlace { location } = role {
shared.insert(
(idx[k], item.as_str().to_string()),
Covered { location, by: None },
);
} else if let Role::Shared { location, by } = role {
let m = &self.members[idx[k]];
let own = cands[k].part.location();
let has_hash = self
.record
.harnesses
.get(m.harness.id())
.is_some_and(|t| t.contains_key(item.as_str()));
if has_hash && own != location {
self.warnings.push(format!(
"{}: the {item} now come from {location} ({by}); the earlier copy in {own} is left in place",
m.harness.id()
));
}
shared.insert(
(idx[k], item.as_str().to_string()),
Covered {
location,
by: Some(by),
},
);
}
}
}
Ok(shared)
}
pub(super) fn examine(
&self,
shared: &BTreeMap<(usize, String), Covered>,
) -> Result<Vec<Examined<'_>>> {
let mut out = Vec::new();
for (i, m) in self.members.iter().enumerate() {
for part in m.profile.parts() {
let path = part.location();
if m.declined.iter().any(|d| d == part.name()) {
out.push(Examined {
member: i,
part,
path,
state: State::Skipped,
existed: false,
by: None,
in_place: false,
});
continue;
}
if let Some(c) = shared.get(&(i, part.name().to_string())) {
out.push(Examined {
member: i,
part,
path: c.location.clone(),
state: State::Shared,
existed: false,
by: c.by.clone(),
in_place: c.by.is_none(),
});
continue;
}
let observed = observe(&self.cx.root, part, &path, &self.markers)?;
let st = state(&observed, &expected(part), self.recorded(i, part), false);
let present = matches!(observed, Observed::Present(_));
let existed = present
|| match &part.kind {
Kind::Files { files, .. } => files
.iter()
.any(|(rel, _)| self.cx.path(&path).join(rel).exists()),
Kind::Region { .. } | Kind::Merge { .. } => self.cx.path(&path).exists(),
Kind::External(_) => false,
};
out.push(Examined {
member: i,
part,
path,
state: st,
existed,
by: None,
in_place: false,
});
}
}
Ok(out)
}
pub(super) fn result(&self, parts: Vec<(usize, PartResult)>) -> InstallResult {
let harnesses = self
.members
.iter()
.enumerate()
.filter(|(_, m)| m.named)
.map(|(i, m)| {
let parts: Vec<PartResult> = parts
.iter()
.filter(|(j, _)| *j == i)
.map(|(_, p)| p.clone())
.collect();
HarnessResult {
harness: m.harness.id().to_string(),
notes: missed_note(m)
.into_iter()
.chain(m.harness.notes(&self.cx, &parts))
.collect(),
parts,
unsupported: m.unsupported.clone(),
}
})
.collect();
InstallResult {
scope: self.cx.scope,
root: self.cx.root.clone(),
harnesses,
warnings: self.warnings.clone(),
missing: self.missing.clone(),
}
}
}
fn missed_note(m: &Member) -> Option<String> {
if m.missed.is_empty() {
return None;
}
let events: Vec<&str> = m.missed.iter().map(|e| e.as_str()).collect();
Some(format!(
"{} has no {} event: those hooks do not run there",
m.harness.id(),
events.join(", ")
))
}
pub(super) fn existing_text(plan: &Plan, path: &Path) -> Result<Option<String>> {
if let Some(planned) = plan.pending(path) {
return Ok(planned.map(str::to_string));
}
read_text(path)
}
fn plan_part(cx: &Context, markers: &Markers, plan: &mut Plan, e: &Examined<'_>) -> Result<()> {
let fs_path = cx.path(&e.path);
match &e.part.kind {
Kind::Files { .. } => {
for (rel, text) in render_files(e.part) {
plan.set(fs_path.join(rel), text);
}
}
Kind::Region { block, .. } => {
let existing = existing_text(plan, &fs_path)?;
if let Some(text) = markers.render(existing.as_deref(), block) {
plan.set(fs_path, text);
}
}
Kind::Merge { ops, .. } => {
let existing = existing_text(plan, &fs_path)?;
if let Some(text) = render_merge(&e.path, existing.as_deref(), ops)? {
plan.set(fs_path, text);
}
}
Kind::External(ext) => plan.externals.push(ext.clone()),
}
Ok(())
}
pub fn install<T: Tool + ?Sized>(tool: &T, opts: &InstallOptions) -> Result<InstallResult> {
let mut run = run(tool, &opts.harnesses, opts.scope, opts.without.as_deref())?;
let shared = run.share()?;
let examined = run.examine(&shared)?;
let mut plan = Plan::default();
let mut record = run.record.clone();
for m in run.members.iter().filter(|m| m.named) {
record
.harnesses
.entry(m.harness.id().to_string())
.or_default();
}
let mut parts = Vec::new();
for e in &examined {
let m = &run.members[e.member];
if !m.named {
continue;
}
let written = match e.state {
State::Absent | State::Stale => true,
State::Edited => opts.force,
_ => false,
};
let action = if !written {
None
} else if !e.existed {
Some(Action::Created)
} else if matches!(e.part.kind, Kind::Files { .. }) {
Some(Action::Rewrote)
} else {
Some(Action::Updated)
};
if written {
plan_part(&run.cx, &run.markers, &mut plan, e)?;
}
let table = record
.harnesses
.entry(m.harness.id().to_string())
.or_default();
match e.state {
State::Skipped => {
table.remove(e.part.name());
}
State::Shared => {}
State::Edited if !written => {}
_ => {
table.insert(e.part.name().to_string(), hash(&expected(e.part)));
}
}
parts.push((
e.member,
PartResult {
part: e.part.name().to_string(),
state: e.state,
action,
path: e.path.clone(),
by: e.by.clone(),
in_place: e.in_place,
},
));
}
if record != run.record || !run.record_path.is_file() {
plan.record = Some((
run.record_path.clone(),
Some(render_record(&record, &tool.record_header())),
));
}
apply_plan(&plan)?;
if opts.without.is_some() {
let store = tool.declined_store(opts.scope)?;
for m in run.members.iter().filter(|m| m.named) {
store.set_declined(m.harness.id(), &m.declined)?;
}
}
Ok(run.result(parts))
}
pub fn expand<T: Tool + ?Sized, S: AsRef<str>>(tool: &T, names: &[S], scope: Scope) -> Vec<String> {
if names.iter().any(|n| n.as_ref() == "all") {
return tool
.harnesses()
.iter()
.filter(|h| h.scopes().contains(&scope))
.map(|h| h.id().to_string())
.collect();
}
names.iter().map(|n| n.as_ref().to_string()).collect()
}
pub fn installed<T: Tool + ?Sized>(tool: &T, scope: Scope) -> Result<Vec<String>> {
let root = tool.root(scope)?;
let path = tool.record_path(scope)?;
let record = read_record(&path, &display(&root, &path))?;
Ok(tool
.harnesses()
.iter()
.map(|h| h.id().to_string())
.filter(|id| record.harnesses.contains_key(id))
.collect())
}
pub fn status<T: Tool + ?Sized, I: IntoIterator<Item = S>, S: AsRef<str>>(
tool: &T,
harnesses: I,
scope: Scope,
) -> Result<InstallResult> {
let named: Vec<String> = harnesses
.into_iter()
.map(|s| s.as_ref().to_string())
.collect();
let mut run = run(tool, &named, scope, None)?;
let shared = run.share()?;
let parts: Vec<(usize, PartResult)> = run
.examine(&shared)?
.into_iter()
.map(|e| {
(
e.member,
PartResult {
part: e.part.name().to_string(),
state: e.state,
action: None,
path: e.path,
by: e.by,
in_place: e.in_place,
},
)
})
.collect();
let parts = parts
.into_iter()
.filter(|(i, _)| run.members[*i].named)
.collect();
Ok(run.result(parts))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_is_relative_under_the_root() {
assert_eq!(
display(Path::new("/r"), Path::new("/r/.tool/h.toml")),
".tool/h.toml"
);
assert_eq!(
display(Path::new("/r"), Path::new("/x/h.toml")),
"/x/h.toml"
);
}
#[test]
fn options_build() {
let o = InstallOptions::new(["claude", "codex"], Scope::User)
.without(["mcp"])
.force(true);
assert_eq!(o.harnesses, ["claude", "codex"]);
assert_eq!(o.scope, Scope::User);
assert_eq!(o.without, Some(vec!["mcp".to_string()]));
assert!(o.force);
assert_eq!(InstallOptions::new(["c"], Scope::Project).without, None);
}
}