use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::catalog::CatalogItem;
use crate::error::{BadRefReason, ItemKind, MindError, Result};
use crate::manifest::InstalledItem;
use crate::sanitize::strip_ansi;
use crate::source::Source;
pub const DEFAULT: &str = "default";
pub type ProfileTable = BTreeMap<String, Vec<String>>;
const MAX_NAME_LEN: usize = 64;
pub fn name_problem(name: &str) -> Option<&'static str> {
let bytes = name.as_bytes();
let Some(&first) = bytes.first() else {
return Some("is empty");
};
if bytes.len() > MAX_NAME_LEN {
return Some("is longer than 64 bytes");
}
if name.contains(':') {
return Some("contains ':', which marks an item pattern ('kind:name'), not a profile");
}
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Some("must start with a lowercase letter or digit");
}
let ok = |b: &u8| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_' || *b == b'-';
if !bytes.iter().all(ok) {
return Some("may contain only lowercase letters, digits, '_', and '-'");
}
None
}
#[cfg(test)]
pub fn is_valid_name(name: &str) -> bool {
name_problem(name).is_none()
}
#[derive(Debug, Clone)]
pub enum Member {
All,
Item {
kind: ItemKind,
name: String,
glob: Option<glob::Pattern>,
},
Profile(String),
}
impl Member {
fn matches(&self, item: &CatalogItem) -> bool {
match self {
Member::All => true,
Member::Item { kind, name, glob } => {
item.kind == *kind
&& match glob {
Some(p) => p.matches(&item.name),
None => item.name == *name,
}
}
Member::Profile(_) => false,
}
}
}
pub fn parse_member(raw: &str) -> std::result::Result<Member, String> {
if raw == "*" {
return Ok(Member::All);
}
if let Some((kind_word, name)) = raw.split_once(':') {
let kind = ItemKind::parse(kind_word).ok_or_else(|| {
format!(
"has unknown kind '{}' (expected one of: {})",
strip_ansi(kind_word),
ItemKind::word_list()
)
})?;
if name.is_empty() {
return Err("has an empty item name".to_string());
}
let glob = if crate::resolve::is_glob(name) {
Some(glob::Pattern::new(name).map_err(|e| format!("is not a valid glob ({e})"))?)
} else {
None
};
return Ok(Member::Item {
kind,
name: name.to_string(),
glob,
});
}
if let Some(reason) = name_problem(raw) {
return Err(format!(
"is neither 'kind:name', '*', nor a profile name: it {reason}"
));
}
Ok(Member::Profile(raw.to_string()))
}
pub fn validate_table(table: &ProfileTable, toml_path: &Path) -> Result<()> {
let err = |msg: String| MindError::MindToml {
path: toml_path.to_path_buf(),
msg: format!("[profiles]: {msg}"),
};
for (name, members) in table {
if let Some(reason) = name_problem(name) {
return Err(err(format!("profile name '{}' {reason}", strip_ansi(name))));
}
for raw in members {
match parse_member(raw) {
Ok(Member::Profile(inc)) if !table.contains_key(&inc) => {
return Err(err(format!(
"profile '{name}': member '{inc}' names a profile the table does not declare"
)));
}
Ok(_) => {}
Err(reason) => {
return Err(err(format!(
"profile '{name}': member '{}' {reason}",
strip_ansi(raw)
)));
}
}
}
}
if let Some(cycle) = find_cycle(table) {
return Err(err(format!(
"profile inclusion cycle: {}",
cycle.join(" -> ")
)));
}
Ok(())
}
fn includes<'a>(table: &'a ProfileTable, name: &str) -> impl Iterator<Item = String> + 'a {
table
.get(name)
.into_iter()
.flatten()
.filter_map(|raw| match parse_member(raw) {
Ok(Member::Profile(p)) => Some(p),
_ => None,
})
}
fn find_cycle(table: &ProfileTable) -> Option<Vec<String>> {
fn dfs(
table: &ProfileTable,
node: &str,
stack: &mut Vec<String>,
done: &mut BTreeSet<String>,
) -> Option<Vec<String>> {
if let Some(at) = stack.iter().position(|s| s == node) {
let mut cycle = stack[at..].to_vec();
cycle.push(node.to_string());
return Some(cycle);
}
if done.contains(node) || !table.contains_key(node) {
return None;
}
stack.push(node.to_string());
for next in includes(table, node) {
if let Some(c) = dfs(table, &next, stack, done) {
return Some(c);
}
}
stack.pop();
done.insert(node.to_string());
None
}
let mut done = BTreeSet::new();
for name in table.keys() {
let mut stack = Vec::new();
if let Some(c) = dfs(table, name, &mut stack, &mut done) {
return Some(c);
}
}
None
}
fn closure(table: &ProfileTable, name: &str) -> BTreeSet<String> {
let mut seen = BTreeSet::new();
let mut todo = vec![name.to_string()];
while let Some(p) = todo.pop() {
if !table.contains_key(&p) || !seen.insert(p.clone()) {
continue;
}
todo.extend(includes(table, &p));
}
seen
}
#[derive(Debug, Clone, Default)]
pub struct Selection {
pub include_default: bool,
pub named: Vec<String>,
pub all: bool,
}
impl Selection {
pub fn from_nested(entry: &crate::mindfile::NestedSource) -> Selection {
Selection {
include_default: entry.default_profile.unwrap_or(true),
named: entry.profiles.clone().unwrap_or_default(),
all: false,
}
}
}
pub fn flags_given(names: &[String], no_default: bool, all: bool) -> Vec<&'static str> {
let mut flags = Vec::new();
if !names.is_empty() {
flags.push("--profile");
}
if no_default {
flags.push("--no-default-profile");
}
if all {
flags.push("--all-profiles");
}
flags
}
pub fn parse_cli_names(values: &[String]) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for value in values {
for part in value.split(',') {
let name = part.trim();
if let Some(reason) = name_problem(name) {
return Err(MindError::InvalidProfileName {
name: name.to_string(),
reason: format!("it {reason}"),
});
}
if !out.iter().any(|n| n == name) {
out.push(name.to_string());
}
}
}
Ok(out)
}
pub fn enabled_set(
table: Option<&ProfileTable>,
selection: &Selection,
source_name: &str,
) -> Result<Vec<String>> {
enabled_with(table, selection, |name, declared| {
MindError::ProfileNotFound {
profile: name.to_string(),
source_name: source_name.to_string(),
declared,
}
})
}
pub fn enabled_set_for_curator(
table: Option<&ProfileTable>,
selection: &Selection,
source_name: &str,
curator: &str,
) -> Result<Vec<String>> {
enabled_with(table, selection, |name, _| MindError::BadReference {
item: format!("curator '{}' [discover].sources entry", strip_ansi(curator)),
referent: format!("profile '{}'", strip_ansi(name)),
reason: BadRefReason::NoMatch,
in_source: strip_ansi(source_name),
})
}
fn enabled_with(
table: Option<&ProfileTable>,
selection: &Selection,
undeclared: impl Fn(&str, Vec<String>) -> MindError,
) -> Result<Vec<String>> {
let empty = ProfileTable::new();
let table = table.unwrap_or(&empty);
let mut out: BTreeSet<String> = BTreeSet::new();
if selection.include_default && table.contains_key(DEFAULT) {
out.insert(DEFAULT.to_string());
}
if selection.all {
out.extend(table.keys().cloned());
}
for name in &selection.named {
if !table.contains_key(name) {
return Err(undeclared(name, table.keys().cloned().collect()));
}
out.insert(name.clone());
}
Ok(out.into_iter().collect())
}
fn bare_key(item: &CatalogItem) -> String {
format!("{}:{}", item.kind.as_str(), item.name)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Expansion {
pub keys: BTreeMap<String, BTreeSet<String>>,
}
impl Expansion {
pub fn key_list(&self) -> Vec<String> {
self.keys.keys().cloned().collect()
}
}
pub fn expand(
table: &ProfileTable,
enabled: &[String],
items: &[CatalogItem],
source_name: &str,
) -> Result<Expansion> {
let own: Vec<&CatalogItem> = items.iter().filter(|i| i.source == source_name).collect();
let mut out = Expansion::default();
for root in enabled {
for profile in closure(table, root) {
for raw in &table[&profile] {
let Ok(member) = parse_member(raw) else {
continue;
};
if matches!(member, Member::Profile(_)) {
continue;
}
let mut hit = false;
for item in own.iter().filter(|i| member.matches(i)) {
hit = true;
out.keys
.entry(bare_key(item))
.or_default()
.insert(root.clone());
}
if !hit && matches!(member, Member::Item { glob: None, .. }) {
return Err(MindError::BadReference {
item: format!("profile '{profile}'"),
referent: strip_ansi(raw),
reason: BadRefReason::NoMatch,
in_source: strip_ansi(source_name),
});
}
}
}
}
Ok(out)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProfileLint {
pub missing_exact: Vec<(String, String)>,
pub empty_globs: Vec<(String, String)>,
pub empty_profiles: Vec<String>,
}
pub fn lint(table: &ProfileTable, items: &[CatalogItem], source_name: &str) -> ProfileLint {
let own: Vec<&CatalogItem> = items.iter().filter(|i| i.source == source_name).collect();
let mut out = ProfileLint::default();
let mut direct_hit: BTreeMap<&str, bool> = BTreeMap::new();
for (profile, members) in table {
let mut any = false;
for raw in members {
let Ok(member) = parse_member(raw) else {
continue;
};
if matches!(member, Member::Profile(_)) {
continue;
}
let hit = own.iter().any(|i| member.matches(i));
any |= hit;
if !hit {
let pair = (profile.clone(), raw.clone());
match member {
Member::Item { glob: None, .. } => out.missing_exact.push(pair),
_ => out.empty_globs.push(pair),
}
}
}
direct_hit.insert(profile, any);
}
for profile in table.keys() {
let reaches = closure(table, profile)
.iter()
.any(|p| direct_hit.get(p.as_str()).copied().unwrap_or(false));
if !reaches {
out.empty_profiles.push(profile.clone());
}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SnapshotDiff {
pub additions: Vec<(String, BTreeSet<String>)>,
pub departures: Vec<String>,
}
pub fn diff(snapshot: &[String], expansion: &Expansion) -> SnapshotDiff {
let snap: BTreeSet<&str> = snapshot.iter().map(String::as_str).collect();
let additions = expansion
.keys
.iter()
.filter(|(k, _)| !snap.contains(k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let departures = snap
.into_iter()
.filter(|k| !expansion.keys.contains_key(*k))
.map(str::to_string)
.collect();
SnapshotDiff {
additions,
departures,
}
}
pub fn carry_reasons(fresh: &mut InstalledItem, previous: &InstalledItem) {
fresh.profiles = previous.profiles.clone();
fresh.explicit = previous.explicit;
}
pub fn tag_reasons(item: &mut InstalledItem, profiles: &BTreeSet<String>, pre_existing: bool) {
if profiles.is_empty() {
return;
}
if pre_existing && item.profiles.is_empty() {
item.explicit = true;
}
let mut set: BTreeSet<String> = item.profiles.drain(..).collect();
set.extend(profiles.iter().cloned());
item.profiles = set.into_iter().collect();
}
pub fn mark_explicit(item: &mut InstalledItem) {
if !item.profiles.is_empty() {
item.explicit = true;
}
}
pub fn record_applied(source: &mut Source, enabled: &[String], applied_keys: &[String]) {
let union = |cur: Option<Vec<String>>, add: &[String]| -> Vec<String> {
let mut set: BTreeSet<String> = cur.unwrap_or_default().into_iter().collect();
set.extend(add.iter().cloned());
set.into_iter().collect()
};
source.profiles = Some(union(source.profiles.take(), enabled));
source.profile_snapshot = Some(union(source.profile_snapshot.take(), applied_keys));
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DisablePlan {
pub was_enabled: bool,
pub remaining_enabled: Option<Vec<String>>,
pub removable: Vec<String>,
pub strip: Vec<(String, Vec<String>)>,
pub new_snapshot: Option<Vec<String>>,
}
pub fn disable_plan(
source: &Source,
items_of_source: &[&InstalledItem],
name: &str,
remaining_expansion: &Expansion,
) -> DisablePlan {
let current: Vec<String> = source.profiles.clone().unwrap_or_default();
if !source.is_profile_tracked() || !current.iter().any(|p| p == name) {
return DisablePlan {
was_enabled: false,
remaining_enabled: source.profiles.clone(),
removable: Vec::new(),
strip: Vec::new(),
new_snapshot: source.profile_snapshot.clone(),
};
}
let remaining: Vec<String> = current.into_iter().filter(|p| p != name).collect();
let mut plan = DisablePlan {
was_enabled: true,
..DisablePlan::default()
};
for item in items_of_source {
if item.profiles.is_empty() {
continue;
}
let kept: Vec<String> = item
.profiles
.iter()
.filter(|p| remaining.contains(p))
.cloned()
.collect();
if kept.len() == item.profiles.len() {
continue;
}
let key: String = item.key().into();
if kept.is_empty() && !item.is_explicit() {
plan.removable.push(key);
} else {
plan.strip.push((key, kept));
}
}
if remaining.is_empty() {
plan.remaining_enabled = None;
plan.new_snapshot = None;
} else {
plan.remaining_enabled = Some(remaining);
plan.new_snapshot = Some(remaining_expansion.key_list());
}
plan
}
pub fn reject_on_item_link(flags: &[&str]) -> Result<()> {
match flags.first() {
Some(flag) => Err(MindError::ProfileFlagOnItemLink {
flag: (*flag).to_string(),
}),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const SRC: &str = "local/o/r";
fn table(rows: &[(&str, &[&str])]) -> ProfileTable {
rows.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect()))
.collect()
}
fn cat(kind: ItemKind, name: &str, source: &str) -> CatalogItem {
CatalogItem {
kind,
name: name.to_string(),
source: source.to_string(),
prefix: Some("ns".to_string()),
path: PathBuf::from(name),
description: None,
when_to_use: None,
link_rel: None,
bin: None,
build: None,
requires: Vec::new(),
ignore: None,
expand: Vec::new(),
hooks: Vec::new(),
}
}
fn catalog() -> Vec<CatalogItem> {
vec![
cat(ItemKind::Skill, "review", SRC),
cat(ItemKind::Skill, "test", SRC),
cat(ItemKind::Skill, "deploy", SRC),
cat(ItemKind::Rule, "style", SRC),
cat(ItemKind::Agent, "planner", SRC),
cat(ItemKind::Agent, "foreign", "local/x/y"),
]
}
fn installed(name: &str, profiles: &[&str], explicit: bool) -> InstalledItem {
InstalledItem {
kind: ItemKind::Skill,
name: name.to_string(),
bare_name: name.to_string(),
source: SRC.to_string(),
commit: "c".to_string(),
hash: "h".to_string(),
store: format!("store/skill/{name}"),
links: Vec::new(),
description: None,
install_hooks: Vec::new(),
dropped_requires: Vec::new(),
profiles: profiles.iter().map(|s| s.to_string()).collect(),
explicit,
version: None,
}
}
fn set(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| s.to_string()).collect()
}
fn validate_msg(t: &ProfileTable) -> String {
match validate_table(t, Path::new("mind.toml")) {
Err(MindError::MindToml { msg, .. }) => msg,
other => panic!("expected a MindToml error, got {other:?}"),
}
}
#[test]
fn name_grammar() {
for ok in ["default", "a", "0", "full-set", "x_y", "a1-b_2"] {
assert!(is_valid_name(ok), "{ok}");
}
assert!(is_valid_name(&"a".repeat(64)), "64 bytes is the limit");
assert!(!is_valid_name(&"a".repeat(65)), "65 bytes is over");
for bad in [
"",
"Full",
"-a",
"_a",
"a b",
"a.b",
"skill:review",
"a:b",
"é",
] {
assert!(!is_valid_name(bad), "{bad:?} must be invalid");
}
let t = table(&[("Bad", &[])]);
let msg = validate_msg(&t);
assert!(msg.starts_with("[profiles]: profile name 'Bad'"), "{msg}");
let t = table(&[("a:b", &[])]);
assert!(validate_msg(&t).contains("contains ':'"));
}
#[test]
fn member_forms() {
assert!(matches!(parse_member("*"), Ok(Member::All)));
match parse_member("skill:review").unwrap() {
Member::Item { kind, name, glob } => {
assert_eq!(kind, ItemKind::Skill);
assert_eq!(name, "review");
assert!(glob.is_none());
}
other => panic!("{other:?}"),
}
match parse_member("agent:*").unwrap() {
Member::Item { kind, glob, .. } => {
assert_eq!(kind, ItemKind::Agent);
assert!(glob.is_some());
}
other => panic!("{other:?}"),
}
match parse_member("command:frontend:build").unwrap() {
Member::Item { kind, name, .. } => {
assert_eq!(kind, ItemKind::Command);
assert_eq!(name, "frontend:build", "kind splits at the first ':'");
}
other => panic!("{other:?}"),
}
assert!(matches!(parse_member("basic"), Ok(Member::Profile(p)) if p == "basic"));
assert!(parse_member("skill:").is_err());
assert!(parse_member("skill:[").is_err(), "malformed glob");
assert!(parse_member("Basic").is_err());
}
#[test]
fn unknown_kind_and_undeclared_profile_members_are_mindtoml_errors() {
let t = table(&[("basic", &["widget:x"])]);
let msg = validate_msg(&t);
assert!(
msg.starts_with("[profiles]: profile 'basic': member 'widget:x'"),
"{msg}"
);
assert!(msg.contains("unknown kind 'widget'"), "{msg}");
let t = table(&[("full", &["basic"])]);
let msg = validate_msg(&t);
assert!(msg.starts_with("[profiles]: "), "{msg}");
assert!(
msg.contains("'basic' names a profile the table does not declare"),
"{msg}"
);
let ok = table(&[
("default", &["basic"]),
("basic", &["skill:review", "rule:style"]),
("full", &["basic", "agent:*"]),
("all", &["*"]),
]);
validate_table(&ok, Path::new("mind.toml")).expect("the spec's example table is valid");
}
#[test]
fn cycles_are_named() {
let t = table(&[("loop", &["loop"])]);
assert_eq!(
validate_msg(&t),
"[profiles]: profile inclusion cycle: loop -> loop"
);
let t = table(&[("basic", &["full"]), ("full", &["basic", "skill:x"])]);
assert_eq!(
validate_msg(&t),
"[profiles]: profile inclusion cycle: basic -> full -> basic"
);
let t = table(&[("a", &["b"]), ("b", &["c"]), ("c", &["a"]), ("d", &["a"])]);
assert_eq!(
validate_msg(&t),
"[profiles]: profile inclusion cycle: a -> b -> c -> a"
);
let t = table(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
validate_table(&t, Path::new("mind.toml")).expect("diamond is acyclic");
}
#[test]
fn enabled_set_default_semantics() {
let t = table(&[("default", &["basic"]), ("basic", &[]), ("full", &[])]);
let plain = Selection {
include_default: true,
..Selection::default()
};
assert_eq!(enabled_set(Some(&t), &plain, SRC).unwrap(), vec!["default"]);
let named = Selection {
include_default: true,
named: vec!["full".into(), "full".into()],
all: false,
};
assert_eq!(
enabled_set(Some(&t), &named, SRC).unwrap(),
vec!["default", "full"],
"naming adds to default, deduped and sorted"
);
let opt_out = Selection {
include_default: false,
named: vec!["full".into()],
all: false,
};
assert_eq!(enabled_set(Some(&t), &opt_out, SRC).unwrap(), vec!["full"]);
let all = Selection {
include_default: false,
named: vec![],
all: true,
};
assert_eq!(
enabled_set(Some(&t), &all, SRC).unwrap(),
vec!["basic", "default", "full"]
);
let no_default = table(&[("basic", &[])]);
assert!(
enabled_set(Some(&no_default), &plain, SRC)
.unwrap()
.is_empty()
);
assert!(enabled_set(None, &plain, SRC).unwrap().is_empty());
}
#[test]
fn enabled_set_undeclared_is_profile_not_found() {
let t = table(&[("basic", &[]), ("default", &[])]);
let sel = Selection {
include_default: true,
named: vec!["nope".into()],
all: false,
};
match enabled_set(Some(&t), &sel, SRC) {
Err(MindError::ProfileNotFound {
profile,
source_name,
declared,
}) => {
assert_eq!(profile, "nope");
assert_eq!(source_name, SRC);
assert_eq!(declared, vec!["basic", "default"]);
}
other => panic!("{other:?}"),
}
match enabled_set(None, &sel, SRC) {
Err(MindError::ProfileNotFound { declared, .. }) => assert!(declared.is_empty()),
other => panic!("{other:?}"),
}
}
#[test]
fn curator_undeclared_profile_is_bad_reference() {
let t = table(&[("basic", &[])]);
let sel = Selection {
include_default: true,
named: vec!["full".into()],
all: false,
};
match enabled_set_for_curator(Some(&t), &sel, "local/n/s", "local/c/c") {
Err(e @ MindError::BadReference { .. }) => {
let msg = e.to_string();
assert!(msg.contains("local/c/c"), "{msg}");
assert!(msg.contains("profile 'full'"), "{msg}");
assert!(msg.contains("local/n/s"), "{msg}");
}
other => panic!("{other:?}"),
}
let ok = Selection {
include_default: false,
named: vec!["basic".into()],
all: false,
};
assert_eq!(
enabled_set_for_curator(Some(&t), &ok, "local/n/s", "local/c/c").unwrap(),
vec!["basic"]
);
}
#[test]
fn parse_cli_names_splits_trims_dedupes() {
let got = parse_cli_names(&["full, basic".into(), "basic".into(), "x".into()]).unwrap();
assert_eq!(got, vec!["full", "basic", "x"]);
for bad in ["Full", "a,,b", "", "skill:x"] {
match parse_cli_names(&[bad.to_string()]) {
Err(MindError::InvalidProfileName { .. }) => {}
other => panic!("{bad:?}: {other:?}"),
}
}
}
#[test]
fn expand_matches_bare_names_of_own_source_and_attributes_to_enabled() {
let t = table(&[
("default", &["basic"]),
("basic", &["skill:review", "rule:style"]),
("full", &["basic", "agent:*", "skill:deploy"]),
]);
let exp = expand(&t, &["full".into()], &catalog(), SRC).unwrap();
assert_eq!(
exp.key_list(),
vec![
"agent:planner",
"rule:style",
"skill:deploy",
"skill:review"
],
"bare keys, own source only (agent:foreign excluded), namespace not applied"
);
assert_eq!(
exp.keys["skill:review"],
set(&["full"]),
"attributed to the enabled profile"
);
let both = expand(&t, &["default".into(), "full".into()], &catalog(), SRC).unwrap();
assert_eq!(both.keys["skill:review"], set(&["default", "full"]));
assert_eq!(both.keys["skill:deploy"], set(&["full"]));
let all = table(&[("all", &["*"])]);
let exp = expand(&all, &["all".into()], &catalog(), SRC).unwrap();
assert_eq!(exp.keys.len(), 5, "'*' is every item of this source");
let ns = table(&[("p", &["skill:ns:review"])]);
assert!(expand(&ns, &["p".into()], &catalog(), SRC).is_err());
}
#[test]
fn exact_miss_is_bad_reference_glob_miss_is_not() {
let t = table(&[("basic", &["skill:missing"]), ("full", &["basic"])]);
match expand(&t, &["full".into()], &catalog(), SRC) {
Err(e @ MindError::BadReference { .. }) => {
let msg = e.to_string();
assert!(
msg.contains("profile 'basic'"),
"names the listing profile: {msg}"
);
assert!(msg.contains("skill:missing"), "{msg}");
assert!(msg.contains(SRC), "{msg}");
}
other => panic!("{other:?}"),
}
let globs = table(&[("p", &["command:*", "skill:rev*"])]);
let exp = expand(&globs, &["p".into()], &catalog(), SRC).unwrap();
assert_eq!(exp.key_list(), vec!["skill:review"]);
let foreign = table(&[("p", &["agent:foreign"])]);
assert!(expand(&foreign, &["p".into()], &catalog(), SRC).is_err());
}
#[test]
fn lint_collects_every_problem() {
let t = table(&[
("a", &["skill:missing", "skill:review", "command:*"]),
("b", &["workflow:*"]),
("c", &["b"]),
("d", &["a"]),
]);
let l = lint(&t, &catalog(), SRC);
assert_eq!(l.missing_exact, vec![("a".into(), "skill:missing".into())]);
assert_eq!(
l.empty_globs,
vec![
("a".into(), "command:*".into()),
("b".into(), "workflow:*".into())
]
);
assert_eq!(l.empty_profiles, vec!["b".to_string(), "c".to_string()]);
}
#[test]
fn diff_reports_additions_and_departures_and_keeps_forgotten_members_out() {
let mut exp = Expansion::default();
exp.keys.insert("skill:review".into(), set(&["default"]));
exp.keys.insert("skill:new".into(), set(&["full"]));
exp.keys.insert("skill:forgotten".into(), set(&["default"]));
let snapshot = vec![
"skill:review".to_string(),
"skill:forgotten".to_string(),
"skill:gone".to_string(),
];
let d = diff(&snapshot, &exp);
assert_eq!(d.additions, vec![("skill:new".to_string(), set(&["full"]))]);
assert_eq!(d.departures, vec!["skill:gone".to_string()]);
}
#[test]
fn tag_reasons_and_explicit_rule() {
let mut fresh = installed("a", &[], false);
tag_reasons(&mut fresh, &set(&["full"]), false);
assert_eq!(fresh.profiles, vec!["full"]);
assert!(!fresh.explicit && !fresh.is_explicit());
tag_reasons(&mut fresh, &set(&["default", "full"]), true);
assert_eq!(fresh.profiles, vec!["default", "full"]);
assert!(
!fresh.explicit,
"an item that already had reasons stays non-explicit"
);
let mut by_name = installed("b", &[], false);
assert!(by_name.is_explicit());
tag_reasons(&mut by_name, &set(&["default"]), true);
assert_eq!(by_name.profiles, vec!["default"]);
assert!(by_name.explicit && by_name.is_explicit());
let mut tagged = installed("c", &["full"], false);
mark_explicit(&mut tagged);
assert!(tagged.explicit);
assert_eq!(tagged.profiles, vec!["full"]);
let mut plain = installed("d", &[], false);
mark_explicit(&mut plain);
assert!(
!plain.explicit && plain.is_explicit(),
"no reasons: already explicit"
);
let mut re = installed("c", &[], false);
carry_reasons(&mut re, &tagged);
assert_eq!(re.profiles, vec!["full"]);
assert!(re.explicit);
}
#[test]
fn record_applied_unions_and_sorts() {
let mut s = crate::source::parse_spec("o/r").unwrap();
assert!(!s.is_profile_tracked());
record_applied(
&mut s,
&["full".into()],
&["skill:b".into(), "skill:a".into()],
);
record_applied(
&mut s,
&["default".into(), "full".into()],
&["skill:a".into()],
);
assert!(s.is_profile_tracked());
assert_eq!(s.profiles.as_deref().unwrap(), ["default", "full"]);
assert_eq!(
s.profile_snapshot.as_deref().unwrap(),
["skill:a", "skill:b"]
);
}
#[test]
fn disable_plan_removes_only_items_left_without_reasons() {
let mut s = crate::source::parse_spec("o/r").unwrap();
s.profiles = Some(vec!["default".into(), "full".into()]);
s.profile_snapshot = Some(vec!["skill:only-full".into(), "skill:both".into()]);
let only_full = installed("only-full", &["full"], false);
let both = installed("both", &["default", "full"], false);
let explicit = installed("explicit", &["full"], true);
let by_name = installed("by-name", &[], false);
let items = [&only_full, &both, &explicit, &by_name];
let mut remaining = Expansion::default();
remaining
.keys
.insert("skill:both".into(), set(&["default"]));
let plan = disable_plan(&s, &items, "full", &remaining);
assert!(plan.was_enabled);
assert_eq!(plan.removable, vec!["skill:only-full".to_string()]);
assert_eq!(
plan.strip,
vec![
("skill:both".to_string(), vec!["default".to_string()]),
("skill:explicit".to_string(), vec![]),
]
);
assert_eq!(plan.remaining_enabled, Some(vec!["default".to_string()]));
assert_eq!(plan.new_snapshot, Some(vec!["skill:both".to_string()]));
s.profiles = Some(vec!["default".into()]);
let only_default = installed("d", &["default"], false);
let plan = disable_plan(&s, &[&only_default], "default", &Expansion::default());
assert_eq!(plan.removable, vec!["skill:d".to_string()]);
assert_eq!(plan.remaining_enabled, None);
assert_eq!(plan.new_snapshot, None);
let plan = disable_plan(&s, &[&only_default], "full", &Expansion::default());
assert!(!plan.was_enabled);
assert!(plan.removable.is_empty() && plan.strip.is_empty());
assert_eq!(plan.remaining_enabled, s.profiles);
}
#[test]
fn profile_flags_on_item_link_are_refused() {
reject_on_item_link(&[]).expect("no flags is fine");
match reject_on_item_link(&["--no-default-profile", "--profile"]) {
Err(MindError::ProfileFlagOnItemLink { flag }) => {
assert_eq!(flag, "--no-default-profile")
}
other => panic!("{other:?}"),
}
}
#[test]
fn parse_cli_names_rejects_blank_and_empty_segments() {
for bad in [" ", "a,", ",a", "a, ,b", "\t", "a,\u{1b}[31mb"] {
match parse_cli_names(&[bad.to_string()]) {
Err(MindError::InvalidProfileName { .. }) => {}
other => panic!("{bad:?}: {other:?}"),
}
}
assert!(parse_cli_names(&[]).unwrap().is_empty());
let got = parse_cli_names(&[" b ,a".into(), "a, b,c".into()]).unwrap();
assert_eq!(got, vec!["b", "a", "c"]);
let e = parse_cli_names(&[" \u{1b}[31mBad ".into()]).unwrap_err();
let msg = e.to_string();
assert!(!msg.contains('\u{1b}'), "{msg}");
assert!(msg.starts_with("invalid profile name"), "{msg}");
}
#[test]
fn profile_not_found_message_is_sanitized_end_to_end() {
let t = table(&[("basic", &[])]);
let sel = Selection {
include_default: false,
named: vec!["\u{1b}[31mfull".into()],
all: false,
};
let e = enabled_set(Some(&t), &sel, "local/\u{1b}[2Jo/r").unwrap_err();
let msg = e.to_string();
assert!(!msg.contains('\u{1b}'), "{msg:?}");
assert!(msg.contains("basic"), "{msg}");
}
#[test]
fn curator_bad_reference_is_sanitized() {
let t = table(&[("basic", &[])]);
let sel = Selection {
include_default: false,
named: vec!["\u{1b}[31mfull".into()],
all: false,
};
let e =
enabled_set_for_curator(Some(&t), &sel, "n/\u{1b}[2Js", "c/\u{1b}[31mc").unwrap_err();
let msg = e.to_string();
assert!(!msg.contains('\u{1b}'), "{msg:?}");
}
#[test]
fn source_derived_text_in_validation_errors_is_sanitized() {
let t = table(&[("p", &["\u{1b}[31mwidget:x"])]);
assert!(!validate_msg(&t).contains('\u{1b}'));
let t = table(&[("p", &["skill:\u{1b}[31m"]), ("q", &["\u{1b}[31mQ"])]);
assert!(!validate_msg(&t).contains('\u{1b}'));
let t = table(&[("\u{1b}[31mbad", &[])]);
assert!(!validate_msg(&t).contains('\u{1b}'));
let t = table(&[("p", &["skill:\u{1b}]0;title\u{7}missing"])]);
let msg = expand(&t, &["p".into()], &catalog(), SRC)
.unwrap_err()
.to_string();
assert!(!msg.contains('\u{1b}'), "{msg:?}");
}
#[test]
fn member_edge_forms() {
assert!(parse_member("").is_err());
assert!(parse_member(":x").is_err(), "empty kind word");
assert!(
parse_member("**").is_err(),
"bare glob is not a profile name"
);
assert!(parse_member("skill:a:b").is_ok(), "colon in name is kept");
assert!(matches!(
parse_member("skill:a?").unwrap(),
Member::Item { glob: Some(_), .. }
));
assert!(matches!(
parse_member("skill:a]").unwrap(),
Member::Item { glob: None, .. }
));
assert!(parse_member("Skill:x").is_err());
}
#[test]
fn command_group_names_match_exactly_and_by_glob() {
let items = vec![
cat(ItemKind::Command, "frontend:build", SRC),
cat(ItemKind::Command, "frontend:lint", SRC),
cat(ItemKind::Command, "backend:build", SRC),
];
let t = table(&[
("exact", &["command:frontend:build"]),
("glob", &["command:frontend:*"]),
]);
let e = expand(&t, &["exact".into()], &items, SRC).unwrap();
assert_eq!(e.key_list(), vec!["command:frontend:build"]);
let g = expand(&t, &["glob".into()], &items, SRC).unwrap();
assert_eq!(
g.key_list(),
vec!["command:frontend:build", "command:frontend:lint"]
);
}
#[test]
fn kind_must_match_and_blank_catalog_is_an_exact_miss() {
let t = table(&[("p", &["rule:review"])]);
assert!(expand(&t, &["p".into()], &catalog(), SRC).is_err());
let t = table(&[("g", &["skill:*", "*"])]);
assert!(expand(&t, &["g".into()], &[], SRC).unwrap().keys.is_empty());
let t = table(&[("x", &["skill:a"])]);
assert!(expand(&t, &["x".into()], &[], SRC).is_err());
}
#[test]
fn expand_overlapping_enabled_profiles_attribute_to_each_root() {
let t = table(&[
("default", &["skill:review"]),
("full", &["default", "skill:deploy"]),
("other", &["skill:review", "skill:test"]),
]);
let enabled: Vec<String> = vec!["default".into(), "full".into(), "other".into()];
let e = expand(&t, &enabled, &catalog(), SRC).unwrap();
assert_eq!(e.keys["skill:review"], set(&["default", "full", "other"]));
assert_eq!(e.keys["skill:deploy"], set(&["full"]));
assert_eq!(e.keys["skill:test"], set(&["other"]));
let twice: Vec<String> = vec!["full".into(), "full".into()];
let once: Vec<String> = vec!["full".into()];
assert_eq!(
expand(&t, &twice, &catalog(), SRC).unwrap(),
expand(&t, &once, &catalog(), SRC).unwrap()
);
let d = table(&[
("a", &["b", "c"]),
("b", &["d"]),
("c", &["d"]),
("d", &["skill:test"]),
]);
let e = expand(&d, &["a".into()], &catalog(), SRC).unwrap();
assert_eq!(e.keys["skill:test"], set(&["a"]));
}
#[test]
fn expand_undeclared_or_empty_enabled_contributes_nothing_and_terminates_on_cycles() {
let t = table(&[("a", &["skill:review"])]);
assert!(
expand(&t, &["ghost".into()], &catalog(), SRC)
.unwrap()
.keys
.is_empty()
);
assert!(expand(&t, &[], &catalog(), SRC).unwrap().keys.is_empty());
let cyc = table(&[("a", &["b", "skill:review"]), ("b", &["a", "skill:test"])]);
let e = expand(&cyc, &["a".into()], &catalog(), SRC).unwrap();
assert_eq!(e.key_list(), vec!["skill:review", "skill:test"]);
let dangling = table(&[("a", &["nope", "skill:review"])]);
let e = expand(&dangling, &["a".into()], &catalog(), SRC).unwrap();
assert_eq!(e.key_list(), vec!["skill:review"]);
}
#[test]
fn lint_terminates_on_cycles_and_tolerates_garbage() {
let cyc = table(&[
("a", &["b"]),
("b", &["a"]),
("self", &["self"]),
("ok", &["a", "skill:review"]),
]);
let l = lint(&cyc, &catalog(), SRC);
assert_eq!(
l.empty_profiles,
vec!["a".to_string(), "b".to_string(), "self".to_string()]
);
assert!(l.missing_exact.is_empty() && l.empty_globs.is_empty());
let junk = table(&[("p", &["widget:x", "Nope", "ghost", "skill:review"])]);
let l = lint(&junk, &catalog(), SRC);
assert_eq!(l, ProfileLint::default());
let only_junk = table(&[("p", &["widget:x"]), ("none", &[])]);
let l = lint(&only_junk, &catalog(), SRC);
assert_eq!(l.empty_profiles, vec!["none".to_string(), "p".to_string()]);
}
#[test]
fn lint_scopes_to_own_source_and_reports_star_on_empty_catalog() {
let t = table(&[("p", &["agent:foreign", "*"])]);
let l = lint(&t, &catalog(), "local/other/none");
assert_eq!(l.missing_exact, vec![("p".into(), "agent:foreign".into())]);
assert_eq!(l.empty_globs, vec![("p".into(), "*".into())]);
assert_eq!(l.empty_profiles, vec!["p".to_string()]);
let t = table(&[("inner", &["skill:review"]), ("outer", &["inner"])]);
let l = lint(&t, &catalog(), SRC);
assert_eq!(l, ProfileLint::default());
}
#[test]
fn diff_edge_cases() {
let none = diff(&[], &Expansion::default());
assert_eq!(none, SnapshotDiff::default());
let mut exp = Expansion::default();
exp.keys.insert("skill:a".into(), set(&["x"]));
let d = diff(&[], &exp);
assert_eq!(d.additions.len(), 1);
assert!(d.departures.is_empty());
let d = diff(&["skill:a".into()], &Expansion::default());
assert!(d.additions.is_empty());
assert_eq!(d.departures, vec!["skill:a".to_string()]);
let d = diff(
&["skill:z".into(), "skill:b".into(), "skill:z".into()],
&Expansion::default(),
);
assert_eq!(
d.departures,
vec!["skill:b".to_string(), "skill:z".to_string()]
);
}
#[test]
fn carry_and_tag_reason_edges() {
let mut fresh = installed("a", &["stale"], true);
let prev = installed("a", &["full"], false);
carry_reasons(&mut fresh, &prev);
assert_eq!(fresh.profiles, vec!["full"]);
assert!(!fresh.explicit, "explicit is carried too, including false");
let mut fresh = installed("a", &["x"], false);
carry_reasons(&mut fresh, &installed("a", &[], false));
assert!(fresh.profiles.is_empty());
let mut fresh = installed("a", &[], false);
carry_reasons(&mut fresh, &installed("a", &["zeta"], false));
tag_reasons(&mut fresh, &set(&["alpha"]), true);
assert_eq!(fresh.profiles, vec!["alpha", "zeta"]);
assert!(!fresh.explicit, "had reasons, so not pre-existing by name");
let mut by_name = installed("b", &[], false);
tag_reasons(&mut by_name, &BTreeSet::new(), true);
assert!(by_name.profiles.is_empty() && !by_name.explicit);
let mut t = installed("c", &[], false);
tag_reasons(&mut t, &set(&["full"]), false);
tag_reasons(&mut t, &set(&["full"]), false);
assert_eq!(t.profiles, vec!["full"]);
assert!(!t.explicit);
let mut e = installed("d", &["full"], true);
tag_reasons(&mut e, &set(&["default"]), true);
assert!(e.explicit);
assert_eq!(e.profiles, vec!["default", "full"]);
mark_explicit(&mut e);
assert!(e.explicit);
}
#[test]
fn record_applied_with_empty_inputs_still_tracks() {
let mut s = crate::source::parse_spec("o/r").unwrap();
record_applied(&mut s, &[], &[]);
assert!(s.is_profile_tracked());
assert_eq!(s.profiles, Some(vec![]));
assert_eq!(s.profile_snapshot, Some(vec![]));
record_applied(&mut s, &["b".into()], &["skill:b".into()]);
record_applied(&mut s, &["a".into()], &[]);
assert_eq!(s.profiles.as_deref().unwrap(), ["a", "b"]);
assert_eq!(s.profile_snapshot.as_deref().unwrap(), ["skill:b"]);
}
#[test]
fn disable_plan_edges() {
let s = crate::source::parse_spec("o/r").unwrap();
let it = installed("a", &["full"], false);
let p = disable_plan(&s, &[&it], "full", &Expansion::default());
assert!(!p.was_enabled);
assert_eq!(p.remaining_enabled, None);
assert_eq!(p.new_snapshot, None);
assert!(p.removable.is_empty() && p.strip.is_empty());
let mut s = crate::source::parse_spec("o/r").unwrap();
s.profiles = Some(vec![]);
s.profile_snapshot = Some(vec!["skill:k".into()]);
let p = disable_plan(&s, &[&it], "full", &Expansion::default());
assert!(!p.was_enabled);
assert_eq!(p.remaining_enabled, Some(vec![]));
assert_eq!(p.new_snapshot, Some(vec!["skill:k".to_string()]));
let mut s = crate::source::parse_spec("o/r").unwrap();
s.profile_snapshot = Some(vec![]);
let p = disable_plan(&s, &[&it], "full", &Expansion::default());
assert!(!p.was_enabled);
assert_eq!(p.remaining_enabled, None);
let mut s = crate::source::parse_spec("o/r").unwrap();
s.profiles = Some(vec!["full".into()]);
s.profile_snapshot = Some(vec!["skill:e".into()]);
let exp = installed("e", &["full"], true);
let by_name = installed("n", &[], false);
let p = disable_plan(&s, &[&exp, &by_name], "full", &Expansion::default());
assert!(p.was_enabled && p.removable.is_empty());
assert_eq!(p.strip, vec![("skill:e".to_string(), vec![])]);
assert_eq!(p.remaining_enabled, None);
assert_eq!(p.new_snapshot, None);
s.profiles = Some(vec!["a".into(), "b".into()]);
let other = installed("o", &["b"], false);
let p = disable_plan(&s, &[&other], "a", &Expansion::default());
assert!(p.was_enabled && p.removable.is_empty() && p.strip.is_empty());
assert_eq!(p.remaining_enabled, Some(vec!["b".to_string()]));
assert_eq!(p.new_snapshot, Some(vec![]));
let p = disable_plan(&s, &[], "a", &Expansion::default());
assert!(p.was_enabled);
let stale = installed("s", &["gone"], false);
let p = disable_plan(&s, &[&stale], "a", &Expansion::default());
assert_eq!(p.removable, vec!["skill:s".to_string()]);
}
#[test]
fn selection_from_nested_and_all_flag() {
let sel = Selection::default();
assert!(!sel.include_default, "Default derive is opt-out");
let t = table(&[("default", &[]), ("x", &[])]);
let s = Selection {
include_default: true,
named: vec!["x".into()],
all: true,
};
assert_eq!(
enabled_set(Some(&t), &s, SRC).unwrap(),
vec!["default", "x"]
);
assert!(
enabled_set(Some(&t), &Selection::default(), SRC)
.unwrap()
.is_empty()
);
let s = Selection {
all: true,
..Selection::default()
};
assert!(enabled_set(None, &s, SRC).unwrap().is_empty());
}
}