use std::fs;
use anyhow::{bail, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use crate::{
fs_ops,
model::{Candidate, Choice, Source, Target},
};
pub fn discover_candidates(sources: &[Source]) -> Result<Vec<Candidate>> {
let mut candidates = Vec::new();
for source in sources {
if !source.path.exists() {
continue;
}
discover_regular(source, &mut candidates)?;
discover_system(source, &mut candidates)?;
}
Ok(candidates)
}
pub fn choose_latest(candidates: &[Candidate]) -> Result<Vec<Choice>> {
let mut sorted = candidates.to_vec();
sorted.sort_by(|a, b| a.skill.cmp(&b.skill));
let mut choices = Vec::new();
let mut idx = 0;
while idx < sorted.len() {
let skill = sorted[idx].skill.clone();
let start = idx;
while idx < sorted.len() && sorted[idx].skill == skill {
idx += 1;
}
let group = &sorted[start..idx];
let mut ranked = group.to_vec();
ranked.sort_by(|a, b| {
b.newest_mtime_nanos
.cmp(&a.newest_mtime_nanos)
.then_with(|| b.priority.cmp(&a.priority))
});
let winner = &ranked[0];
let tied: Vec<_> = ranked
.iter()
.filter(|c| c.newest_mtime_nanos == winner.newest_mtime_nanos)
.collect();
if tied.len() > 1 {
let first_sig = &tied[0].content_signature;
if tied.iter().any(|c| &c.content_signature != first_sig) {
let details = tied
.iter()
.map(|c| format!(" - {}: {} ({})", c.skill, c.source, c.path))
.collect::<Vec<_>>()
.join("\n");
bail!("ambiguous newest source for skill `{skill}`:\n{details}");
}
}
choices.push(Choice {
skill,
source: winner.source.clone(),
path: winner.path.clone(),
newest_mtime_nanos: winner.newest_mtime_nanos,
candidate_count: group.len(),
});
}
choices.sort_by(|a, b| a.skill.cmp(&b.skill));
Ok(choices)
}
pub fn reconcile_target(target: &Target, sync: bool, dry_run: bool) -> Result<Vec<Choice>> {
let candidates = discover_candidates(&target.sources)?;
let choices = choose_latest(&candidates)?;
if dry_run {
print_choices(target, &choices, sync);
return Ok(choices);
}
write_mirror(target, &choices)?;
if sync {
sync_target(target)?;
}
println!(
"reconciled {} skills into {}",
choices.len(),
target.mirror_path
);
Ok(choices)
}
pub fn sync_target(target: &Target) -> Result<()> {
for sync_path in &target.sync_paths {
write_flat_from_mirror(&target.mirror_path, sync_path)?;
}
for path in &target.stale_codex_skill_paths {
fs_ops::remove_codex_skills(path)?;
}
Ok(())
}
pub fn write_flat_from_mirror(mirror: &Utf8Path, dest: &Utf8Path) -> Result<()> {
let staging = Utf8PathBuf::from(format!("{dest}.skillnet-tmp"));
if staging.exists() {
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
for skill in mirror_skill_dirs(mirror)? {
fs_ops::copy_dir(&skill, &staging.join(skill.file_name().unwrap_or_default()))?;
}
fs_ops::replace_dir(&staging, dest)
}
pub fn mirror_skill_dirs(mirror: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
if !mirror.exists() {
return Ok(Vec::new());
}
let mut dirs = Vec::new();
for entry in fs::read_dir(mirror)? {
let entry = entry?;
let path = Utf8PathBuf::from_path_buf(entry.path())
.map_err(|p| anyhow::anyhow!("non-UTF-8 path in mirror: {}", p.display()))?;
if path.is_dir() && path.join("SKILL.md").is_file() {
dirs.push(path);
}
}
dirs.sort();
Ok(dirs)
}
fn write_mirror(target: &Target, choices: &[Choice]) -> Result<()> {
let staging = Utf8PathBuf::from(format!("{}.skillnet-tmp", target.mirror_path));
if staging.exists() {
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
for choice in choices {
fs_ops::copy_dir(&choice.path, &staging.join(&choice.skill))?;
}
write_manifest(target, choices, &staging)?;
fs_ops::replace_dir(&staging, &target.mirror_path)
}
fn write_manifest(target: &Target, choices: &[Choice], output: &Utf8Path) -> Result<()> {
let mut body = String::new();
body.push_str("# Skill Reconciliation\n\n");
body.push_str("Generated by `skillnet reconcile`.\n\n");
body.push_str("## Rule\n\n");
body.push_str("Generated outputs are flat sets of skill directories. Candidates are read from live source roots. Claude/Codex `.system/*` children are flattened into normal global skills.\n\n");
body.push_str("When the same skill exists in more than one source, the source tree with the newest file mtime wins. Exact newest-time ties are accepted only when the tied directory contents are identical.\n\n");
body.push_str("## Sources\n\n");
for source in &target.sources {
if source.path.exists() {
body.push_str(&format!("- {}: `{}`\n", source.label, source.path));
}
}
body.push_str("\n## Choices\n\n");
body.push_str("| Skill | Selected Source | Selected Path | Newest Mtime | Candidates |\n");
body.push_str("|---|---|---|---:|---:|\n");
for choice in choices {
body.push_str(&format!(
"| `{}` | `{}` | `{}` | `{}` | {} |\n",
choice.skill,
choice.source,
choice.path,
choice.newest_mtime_nanos,
choice.candidate_count
));
}
fs::write(output.join("RECONCILIATION.md"), body).context("failed to write manifest")
}
fn discover_regular(source: &Source, candidates: &mut Vec<Candidate>) -> Result<()> {
for entry in fs::read_dir(&source.path)? {
let entry = entry?;
let path = Utf8PathBuf::from_path_buf(entry.path())
.map_err(|p| anyhow::anyhow!("non-UTF-8 path in source: {}", p.display()))?;
if path.file_name() == Some(".system") {
continue;
}
if path.is_dir() && path.join("SKILL.md").is_file() {
candidates.push(candidate_from_path(source, &path, source.label.clone())?);
}
}
Ok(())
}
fn discover_system(source: &Source, candidates: &mut Vec<Candidate>) -> Result<()> {
let system_root = source.path.join(".system");
if !system_root.exists() {
return Ok(());
}
for entry in fs::read_dir(system_root)? {
let entry = entry?;
let path = Utf8PathBuf::from_path_buf(entry.path())
.map_err(|p| anyhow::anyhow!("non-UTF-8 path in system source: {}", p.display()))?;
if path.is_dir() && path.join("SKILL.md").is_file() {
candidates.push(candidate_from_path(
source,
&path,
format!("{}-system", source.label),
)?);
}
}
Ok(())
}
pub fn candidate_from_path(source: &Source, path: &Utf8Path, label: String) -> Result<Candidate> {
Ok(Candidate {
skill: path
.file_name()
.context("skill path has no final component")?
.to_string(),
source: label,
priority: source.priority,
path: path.to_path_buf(),
newest_mtime_nanos: fs_ops::newest_mtime_nanos(path)?,
content_signature: fs_ops::content_signature(path)?,
})
}
fn print_choices(target: &Target, choices: &[Choice], sync: bool) {
println!("# {} -> {}", target.name, target.mirror_path);
if choices.is_empty() {
println!("no skills found");
}
for choice in choices {
println!(
"{}\t{}\t{}\t{}",
choice.skill, choice.source, choice.path, choice.candidate_count
);
}
if sync {
println!(
"sync-back: {}",
target
.sync_paths
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", ")
);
println!(
"remove-stale-codex-skills: {}",
target
.stale_codex_skill_paths
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
}
#[cfg(test)]
mod tests {
use std::{fs, thread, time::Duration};
use tempfile::tempdir;
use super::*;
fn skill(root: &Utf8Path, name: &str, body: &str) -> Utf8PathBuf {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), body).unwrap();
dir
}
#[test]
fn newest_candidate_wins() {
let tmp = tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
let older = root.join("older");
let newer = root.join("newer");
fs::create_dir_all(&older).unwrap();
fs::create_dir_all(&newer).unwrap();
skill(&older, "x", "old");
thread::sleep(Duration::from_millis(5));
skill(&newer, "x", "new");
let candidates = discover_candidates(&[
Source {
label: "older".into(),
path: older,
priority: 2,
},
Source {
label: "newer".into(),
path: newer,
priority: 1,
},
])
.unwrap();
let choices = choose_latest(&candidates).unwrap();
assert_eq!(choices[0].source, "newer");
}
#[test]
fn identical_ties_are_allowed() {
let tmp = tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
let a = skill(&root, "a", "same");
let b = skill(&root, "b", "same");
let source = Source {
label: "s".into(),
path: root,
priority: 1,
};
let mut c1 = candidate_from_path(&source, &a, "a".into()).unwrap();
let mut c2 = candidate_from_path(&source, &b, "b".into()).unwrap();
c1.skill = "x".into();
c2.skill = "x".into();
c2.newest_mtime_nanos = c1.newest_mtime_nanos;
choose_latest(&[c1, c2]).unwrap();
}
#[test]
fn conflicting_ties_fail() {
let tmp = tempdir().unwrap();
let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
let a = skill(&root, "a", "one");
let b = skill(&root, "b", "two");
let source = Source {
label: "s".into(),
path: root,
priority: 1,
};
let mut c1 = candidate_from_path(&source, &a, "a".into()).unwrap();
let mut c2 = candidate_from_path(&source, &b, "b".into()).unwrap();
c1.skill = "x".into();
c2.skill = "x".into();
c2.newest_mtime_nanos = c1.newest_mtime_nanos;
assert!(choose_latest(&[c1, c2]).is_err());
}
}