use std::collections::{BTreeSet, HashMap};
use std::io::Write;
use std::path::PathBuf;
use serde::Serialize;
use crate::catalog;
use crate::error::{MindError, Result};
use crate::manifest::Manifest;
use crate::mindfile::MindToml;
use crate::paths::Paths;
use crate::source::{Registry, Source};
#[derive(Serialize)]
struct DumpDoc {
source: DumpSource,
discover: DumpDiscover,
}
#[derive(Serialize)]
struct DumpSource {
description: String,
}
#[derive(Serialize)]
struct DumpDiscover {
sources: Vec<DumpEntry>,
}
#[derive(Serialize)]
struct DumpEntry {
source: String,
#[serde(rename = "namespace", skip_serializing_if = "Option::is_none")]
namespace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
roots: Option<Vec<String>>,
#[serde(rename = "flat-skills", skip_serializing_if = "Option::is_none")]
flat_skills: Option<bool>,
#[serde(rename = "pin-ref", skip_serializing_if = "Option::is_none")]
pin_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
install: Option<bool>,
#[serde(rename = "install-items", skip_serializing_if = "Option::is_none")]
install_items: Option<Vec<String>>,
}
pub fn run(paths: &Paths, output: Option<PathBuf>, whole_sources: bool) -> Result<()> {
if crate::render::ctx().json {
eprintln!("note: --json does not apply to dump; output is always TOML");
}
let registry = Registry::load(paths)?;
let manifest = Manifest::load(paths)?;
let entries = build_entries(paths, ®istry, &manifest, whole_sources)?;
let doc = DumpDoc {
source: DumpSource {
description: "Generated by `mind dump`. Meld this file to reproduce the recorded \
source set and install selection."
.to_string(),
},
discover: DumpDiscover { sources: entries },
};
let text = toml::to_string(&doc).map_err(|e| MindError::TomlWrite {
path: output.clone().unwrap_or_else(|| PathBuf::from("<stdout>")),
source: e,
})?;
match output {
None => {
let stdout = std::io::stdout();
let mut out = stdout.lock();
out.write_all(text.as_bytes())
.and_then(|_| out.flush())
.map_err(|e| MindError::io("<stdout>", e))
}
Some(path) => {
std::fs::write(&path, &text).map_err(|e| MindError::io(&path, e))
}
}
}
fn build_entries(
paths: &Paths,
registry: &Registry,
manifest: &Manifest,
whole_sources: bool,
) -> Result<Vec<DumpEntry>> {
let mut entries = Vec::with_capacity(registry.sources.len());
for source in ®istry.sources {
let entry = build_entry(paths, source, manifest, whole_sources)?;
entries.push(entry);
}
Ok(entries)
}
fn build_entry(
paths: &Paths,
source: &Source,
manifest: &Manifest,
whole_sources: bool,
) -> Result<DumpEntry> {
let source_spec = source.url.clone();
let effective_alias = effective_prefix(paths, source);
let roots = source.roots.clone();
let flat_skills = source.flat_skills.then_some(true);
let pin_ref = source.commit.clone();
let (install, install_items) = if whole_sources {
(Some(true), None)
} else {
compute_install_directive(paths, source, manifest)?
};
Ok(DumpEntry {
source: source_spec,
namespace: effective_alias,
roots,
flat_skills,
pin_ref,
install,
install_items,
})
}
fn effective_prefix(paths: &Paths, source: &Source) -> Option<String> {
if let Some(alias) = &source.alias {
if !alias.is_empty() {
return Some(alias.clone());
}
return None;
}
let clone_root = source.clone_dir(paths);
if let Ok(Some(mt)) = MindToml::load(&clone_root)
&& let Some(prefix) = mt.source.prefix
&& !prefix.is_empty()
{
return Some(prefix);
}
None
}
fn compute_install_directive(
paths: &Paths,
source: &Source,
manifest: &Manifest,
) -> Result<(Option<bool>, Option<Vec<String>>)> {
let mut offered_items = Vec::new();
catalog::scan_source(paths, source, &mut offered_items)?;
let offered: BTreeSet<String> = offered_items
.iter()
.map(|item| format!("{}:{}", item.kind.as_str(), item.name))
.collect();
let installed_for_source: HashMap<String, ()> = manifest
.items
.values()
.filter(|item| item.source == source.name)
.map(|item| {
let bare_ref = format!("{}:{}", item.kind.as_str(), item.bare_name);
(bare_ref, ())
})
.collect();
let installed_and_offered: BTreeSet<String> = offered
.iter()
.filter(|ref_| installed_for_source.contains_key(*ref_))
.cloned()
.collect();
if offered.is_empty() {
return Ok((Some(false), None));
}
if installed_and_offered.is_empty() {
return Ok((Some(false), None));
}
if installed_and_offered == offered {
return Ok((Some(true), None));
}
let items: Vec<String> = installed_and_offered.into_iter().collect();
Ok((None, Some(items)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use crate::manifest::InstalledItem;
use crate::source::Source;
fn make_source(name: &str, url: &str, commit: Option<&str>) -> Source {
Source {
name: name.to_string(),
url: url.to_string(),
host: "local".to_string(),
owner: "test".to_string(),
repo: name.split('/').next_back().unwrap_or(name).to_string(),
commit: commit.map(str::to_string),
description: None,
alias: None,
pin: crate::source::Pin::default(),
roots: None,
flat_skills: false,
origin: None,
plugin_version: None,
install_hooks: vec![],
install_hook: None,
install_hook_commit: None,
}
}
fn make_installed(kind: ItemKind, name: &str, bare: &str, source: &str) -> InstalledItem {
InstalledItem {
kind,
name: name.to_string(),
bare_name: bare.to_string(),
source: source.to_string(),
commit: "abc".to_string(),
hash: "def".to_string(),
store: "store/path".to_string(),
links: vec![],
description: None,
}
}
#[test]
fn filtering_all_installed_yields_install_true() {
let entry = DumpEntry {
source: "/some/path".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: None,
install: Some(true),
install_items: None,
};
let doc = DumpDoc {
source: DumpSource {
description: "test".into(),
},
discover: DumpDiscover {
sources: vec![entry],
},
};
let text = toml::to_string(&doc).unwrap();
assert!(text.contains("install = true"), "must emit install = true");
assert!(
!text.contains("install-items"),
"must NOT emit install-items when install = true"
);
let back: MindToml = toml::from_str(&text)
.unwrap_or_else(|e| panic!("install=true output must parse as MindToml: {e}"));
let ns = &back.discover.unwrap().sources[0];
assert!(ns.install);
assert!(ns.install_items.is_none());
}
#[test]
fn filtering_none_installed_yields_install_false() {
let entry = DumpEntry {
source: "/some/path".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: None,
install: Some(false),
install_items: None,
};
let doc = DumpDoc {
source: DumpSource {
description: "test".into(),
},
discover: DumpDiscover {
sources: vec![entry],
},
};
let text = toml::to_string(&doc).unwrap();
assert!(
text.contains("install = false"),
"must emit install = false"
);
assert!(
!text.contains("install-items"),
"must NOT emit install-items when none installed"
);
let back: MindToml = toml::from_str(&text)
.unwrap_or_else(|e| panic!("install=false output must parse as MindToml: {e}"));
let ns = &back.discover.unwrap().sources[0];
assert!(!ns.install);
assert!(ns.install_items.is_none());
}
#[test]
fn filtering_proper_subset_yields_install_items() {
let items = vec!["agent:dev".to_string(), "skill:review".to_string()];
let entry = DumpEntry {
source: "/some/path".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: None,
install: None,
install_items: Some(items.clone()),
};
let doc = DumpDoc {
source: DumpSource {
description: "test".into(),
},
discover: DumpDiscover {
sources: vec![entry],
},
};
let text = toml::to_string(&doc).unwrap();
assert!(
text.contains("install-items"),
"must emit install-items for subset"
);
assert!(
!text.contains("install = true"),
"must NOT emit install = true when emitting install-items"
);
let back: MindToml = toml::from_str(&text)
.unwrap_or_else(|e| panic!("install_items output must parse as MindToml: {e}"));
let ns = &back.discover.unwrap().sources[0];
assert!(
!ns.install,
"install bool must be false/absent for subset form"
);
let got = ns
.install_items
.as_deref()
.expect("install_items must be present");
assert_eq!(got, &["agent:dev", "skill:review"][..]);
}
#[test]
fn empty_install_items_is_never_emitted() {
let entry = DumpEntry {
source: "/some/path".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: None,
install: Some(false),
install_items: None, };
let doc = DumpDoc {
source: DumpSource {
description: "test".into(),
},
discover: DumpDiscover {
sources: vec![entry],
},
};
let text = toml::to_string(&doc).unwrap();
assert!(
!text.contains("install-items"),
"install_items=[] must never appear; got: {text}"
);
assert!(text.contains("install = false"));
}
#[test]
fn emitted_toml_parses_as_valid_mindtoml() {
let doc = DumpDoc {
source: DumpSource {
description: "Generated by `mind dump`. Meld this file to reproduce the \
recorded source set and install selection."
.to_string(),
},
discover: DumpDiscover {
sources: vec![
DumpEntry {
source: "/path/to/repo".into(),
namespace: Some("pfx".into()),
roots: Some(vec!["packages".into()]),
flat_skills: Some(true),
pin_ref: Some("deadbeefdeadbeef1234".into()),
install: Some(true),
install_items: None,
},
DumpEntry {
source: "https://github.com/owner/repo".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: None,
install: None,
install_items: Some(vec!["skill:review".into(), "agent:dev".into()]),
},
],
},
};
let text =
toml::to_string(&doc).unwrap_or_else(|e| panic!("serialization must not fail: {e}"));
let back: MindToml = toml::from_str(&text)
.unwrap_or_else(|e| panic!("emitted TOML must parse as MindToml: {e}\nTOML:\n{text}"));
assert!(
back.source.description.is_some(),
"emitted TOML must carry [source].description"
);
assert!(back.items.is_empty(), "emitted TOML must have no [[items]]");
let disc = back.discover.expect("must have [discover]");
assert_eq!(disc.sources.len(), 2);
let e0 = &disc.sources[0];
assert!(e0.install, "first entry: install must be true");
assert_eq!(e0.namespace.as_deref(), Some("pfx"));
assert_eq!(e0.roots.as_deref(), Some(&["packages".to_string()][..]));
assert!(
text.contains("flat-skills = true"),
"must emit flat-skills = true"
);
assert!(
e0.flat_skills,
"first entry: flat_skills must parse back true"
);
assert!(
e0.pin_ref.is_some(),
"pin-ref must be present for pinned entry"
);
let e1 = &disc.sources[1];
assert!(!e1.install, "second entry: install must be false");
assert!(
!e1.flat_skills,
"second entry: flat_skills must parse back false (no key emitted)"
);
let items = e1
.install_items
.as_deref()
.expect("install_items must be present");
assert_eq!(items, &["skill:review", "agent:dev"][..]);
}
#[test]
fn spec_string_round_trips_for_local_path() {
let source = make_source("local/dev/agents", "/home/dev/agents", Some("abc123"));
let spec = source.url.clone();
let parsed = crate::source::parse_spec(&spec)
.unwrap_or_else(|e| panic!("local spec must round-trip: {e}"));
assert_eq!(
parsed.url, source.url,
"local path spec must round-trip through parse_spec"
);
}
#[test]
fn spec_string_round_trips_for_https_url() {
let url = "https://github.com/owner/repo";
let source = make_source("github.com/owner/repo", url, None);
let spec = source.url.clone();
let parsed = crate::source::parse_spec(&spec)
.unwrap_or_else(|e| panic!("https spec must round-trip: {e}"));
assert_eq!(parsed.url, url);
}
#[test]
fn pin_ref_emitted_when_commit_recorded_regardless_of_pin_kind() {
let entry_with_commit = DumpEntry {
source: "/a/b".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: Some("deadbeefdeadbeef".into()),
install: Some(true),
install_items: None,
};
let entry_no_commit = DumpEntry {
source: "/a/b".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: None,
install: Some(false),
install_items: None,
};
let text_with = toml::to_string(&DumpDoc {
source: DumpSource {
description: "t".into(),
},
discover: DumpDiscover {
sources: vec![entry_with_commit],
},
})
.unwrap();
let text_without = toml::to_string(&DumpDoc {
source: DumpSource {
description: "t".into(),
},
discover: DumpDiscover {
sources: vec![entry_no_commit],
},
})
.unwrap();
assert!(
text_with.contains("pin-ref"),
"must emit pin-ref when a commit is recorded: {text_with}"
);
assert!(
text_with.contains("deadbeefdeadbeef"),
"must emit the recorded commit sha as pin-ref: {text_with}"
);
assert!(
!text_with.contains("follow-branch"),
"must NOT emit follow-branch (replaced by pin-ref): {text_with}"
);
assert!(
!text_without.contains("pin-ref"),
"must NOT emit pin-ref when no commit is recorded: {text_without}"
);
let _: MindToml = toml::from_str(&text_with)
.unwrap_or_else(|e| panic!("pinned entry must parse: {e}\n{text_with}"));
let _: MindToml = toml::from_str(&text_without)
.unwrap_or_else(|e| panic!("unpinned entry must parse: {e}\n{text_without}"));
}
#[test]
fn install_items_sorted_deterministically() {
let mut items = [
"skill:review".to_string(),
"agent:dev".to_string(),
"rule:style".to_string(),
];
items.sort();
assert_eq!(items[0], "agent:dev");
assert_eq!(items[1], "rule:style");
assert_eq!(items[2], "skill:review");
}
#[test]
fn emitted_toml_has_no_items_of_its_own() {
let doc = DumpDoc {
source: DumpSource {
description: "Generated by `mind dump`. test".into(),
},
discover: DumpDiscover { sources: vec![] },
};
let text = toml::to_string(&doc).unwrap();
let back: MindToml =
toml::from_str(&text).unwrap_or_else(|e| panic!("empty doc must parse: {e}"));
assert!(back.items.is_empty(), "must have no [[items]]");
assert!(
!back.is_authoritative(),
"empty discover.sources must not be authoritative (only sources, no item globs)"
);
}
#[test]
fn whole_sources_entry_has_install_true() {
let entry = DumpEntry {
source: "/a/b".into(),
namespace: None,
roots: None,
flat_skills: None,
pin_ref: None,
install: Some(true),
install_items: None,
};
let text = toml::to_string(&DumpDoc {
source: DumpSource {
description: "t".into(),
},
discover: DumpDiscover {
sources: vec![entry],
},
})
.unwrap();
assert!(text.contains("install = true"));
let back: MindToml = toml::from_str(&text).unwrap();
assert!(back.discover.unwrap().sources[0].install);
}
#[test]
fn dependency_item_treated_like_any_installed_item() {
let item = make_installed(ItemKind::Skill, "pfx:review", "review", "local/dev/agents");
let bare_ref = format!("{}:{}", item.kind.as_str(), item.bare_name);
assert_eq!(bare_ref, "skill:review");
}
#[test]
fn namespace_field_emitted_not_as_key() {
let entry = DumpEntry {
source: "/a/b".into(),
namespace: Some("pfx".into()),
roots: None,
flat_skills: None,
pin_ref: None,
install: Some(true),
install_items: None,
};
let doc = DumpDoc {
source: DumpSource {
description: "t".into(),
},
discover: DumpDiscover {
sources: vec![entry],
},
};
let text = toml::to_string(&doc).unwrap();
assert!(
text.contains("namespace = \"pfx\""),
"must emit `namespace = \"pfx\"` (canonical key): {text}"
);
assert!(
!text.contains("as = "),
"must NOT emit the legacy `as = ` key: {text}"
);
let back: MindToml = toml::from_str(&text)
.unwrap_or_else(|e| panic!("namespace output must parse as MindToml: {e}\n{text}"));
let ns = &back.discover.unwrap().sources[0];
assert_eq!(
ns.namespace.as_deref(),
Some("pfx"),
"emitted namespace key must populate NestedSource::namespace"
);
assert!(
ns.alias.is_none(),
"emitted namespace key must NOT populate NestedSource::alias"
);
}
#[test]
fn dump_8_empty_registry_produces_valid_super_source() {
let doc = DumpDoc {
source: DumpSource {
description: "Generated by `mind dump`. Meld this file to reproduce the \
recorded source set and install selection."
.to_string(),
},
discover: DumpDiscover { sources: vec![] },
};
let text = toml::to_string(&doc).unwrap();
let back: MindToml =
toml::from_str(&text).unwrap_or_else(|e| panic!("empty-registry doc must parse: {e}"));
let disc = back.discover.expect("must have [discover]");
assert!(disc.sources.is_empty(), "sources must be empty");
}
}