use std::collections::BTreeSet;
use std::path::Path;
use trusty_agents_common::agents::quarantine_receipt::QuarantineReport;
use crate::core::asset_duplicates::{DuplicateStem, name_duplicates, scan_duplicate_stems};
use crate::core::paths::FrameworkPaths;
use crate::core::project_tier_agent_strays::project_agent_tier;
use crate::core::skill_deploy_tiers::project_skill_tier;
use crate::core::skill_tiers::list_source_stems;
use crate::core::skill_unmanaged::bundled_skill_dirs;
const MAX_NAMED: usize = 5;
pub fn launch_asset_notices(
fw: &FrameworkPaths,
project_dir: &Path,
quarantine: Option<&QuarantineReport>,
) -> Vec<String> {
[
quarantine.and_then(agent_notice),
skill_notice(fw, project_dir),
duplicate_notice(project_dir),
]
.into_iter()
.flatten()
.collect()
}
fn agent_notice(report: &QuarantineReport) -> Option<String> {
if !report.wrote_anything() {
return None;
}
let mut names: Vec<&str> = report.moved.iter().map(|m| m.name.as_str()).collect();
names.sort_unstable();
let shown = names.iter().take(MAX_NAMED).copied().collect::<Vec<_>>();
let rest = names.len().saturating_sub(shown.len());
let listed = if rest == 0 {
shown.join(", ")
} else {
format!("{} (+{rest} more)", shown.join(", "))
};
Some(format!(
"agents quarantined {} ({listed}), {} could not be moved — each has a backup and an \
inert `.md.disabled` sibling under `.trusty-mpm/agent-quarantine/`",
report.moved.len(),
report.failed.len()
))
}
fn skill_notice(fw: &FrameworkPaths, project_dir: &Path) -> Option<String> {
let dir = project_skill_tier(project_dir);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries.flatten().count(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
return Some(format!(
"skills stray UNDETERMINED — the project skill tier {} could not be listed: {e}",
dir.display()
));
}
};
let bundled: BTreeSet<String> = list_source_stems(&fw.skill_source_dir()).unwrap_or_default();
if bundled.is_empty() {
if entries == 0 {
return None;
}
return Some(format!(
"skills stray UNDETERMINED — no bundled skill roster could be built, so the {entries} \
entr(y/ies) in {} could not be classified (run `tm install`)",
dir.display()
));
}
let found: Vec<String> = bundled_skill_dirs(&dir, &bundled)
.into_iter()
.map(|skill| skill.stem)
.collect();
if found.is_empty() {
return None;
}
let shown: Vec<&str> = found.iter().take(MAX_NAMED).map(String::as_str).collect();
let rest = found.len().saturating_sub(shown.len());
let listed = if rest == 0 {
shown.join(", ")
} else {
format!("{} (+{rest} more)", shown.join(", "))
};
Some(format!(
"skills stray {} ({listed}) at the project tier {} — bundled skills are user-tier only \
since #6586, so nothing refreshes these; preview the removal with `tm doctor \
--fix-skills`",
found.len(),
dir.display()
))
}
fn duplicate_notice(project_dir: &Path) -> Option<String> {
let mut found: Vec<DuplicateStem> = Vec::new();
let mut undetermined: Vec<String> = Vec::new();
for dir in [
project_agent_tier(project_dir),
project_skill_tier(project_dir),
] {
match scan_duplicate_stems(&dir) {
Ok(dups) => found.extend(dups),
Err(e) => undetermined.push(e.to_string()),
}
}
if found.is_empty() {
if undetermined.is_empty() {
return None;
}
return Some(format!(
"duplicates UNDETERMINED — {}",
undetermined.join("; ")
));
}
found.sort_by(|a, b| a.stem.cmp(&b.stem));
let suffix = if undetermined.is_empty() {
String::new()
} else {
format!("; UNDETERMINED: {}", undetermined.join("; "))
};
Some(format!(
"duplicates {} ({}) — one name, two entries in the same tier, so only one of them \
loads; delete or rename one (tm does not repair this){suffix}",
found.len(),
name_duplicates(&found, MAX_NAMED)
))
}
pub struct PrepScope<'a> {
pub kind: &'a str,
pub session: Option<&'a dyn std::fmt::Display>,
pub dir: &'a Path,
}
pub fn log_prep_findings(roster_errors: &[String], asset_notices: &[String], scope: PrepScope<'_>) {
let session: &dyn std::fmt::Display = scope.session.unwrap_or(&"");
for err in roster_errors {
tracing::error!(
scope = scope.kind,
session = %session,
dir = %scope.dir.display(),
err = %err,
"roster provisioning gap (session still launches with its trusty-mpm identity)"
);
}
for notice in asset_notices {
tracing::warn!(
scope = scope.kind,
session = %session,
dir = %scope.dir.display(),
notice = %notice,
"asset tier finding"
);
}
}
#[cfg(test)]
#[path = "asset_notices_tests.rs"]
mod tests;