use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::catalog::{self, CatalogItem};
use crate::error::Result;
use crate::git;
use crate::mindfile::MindToml;
use crate::paths::Paths;
use crate::source::{Registry, parse_spec};
static PREVIEW_NONCE: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct SourcePreview {
pub temp_dir: PathBuf,
pub spec: String,
pub items: Vec<CatalogItem>,
pub name: String,
pub url: String,
}
impl Drop for SourcePreview {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.temp_dir);
}
}
fn preview_temp_name(repo: &str, pid: u32, nonce: u64) -> String {
format!("preview-{repo}-{pid}-{nonce}")
}
#[allow(dead_code)] pub fn preview(paths: &Paths, spec: &str) -> Result<SourcePreview> {
let source = parse_spec(spec)?;
let nonce = PREVIEW_NONCE.fetch_add(1, Ordering::SeqCst);
let temp_dir = paths.mind_home.join(".tmp").join(preview_temp_name(
&source.repo,
std::process::id(),
nonce,
));
if temp_dir.exists() {
std::fs::remove_dir_all(&temp_dir)
.map_err(|e| crate::error::MindError::io(&temp_dir, e))?;
}
crate::paths::mkdir_p(&temp_dir)?;
if let Err(e) = git::clone(&source.url, &temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(e);
}
let mut items = Vec::new();
let result = catalog::scan_source_at(&temp_dir, &source, &mut items);
if let Err(e) = result {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(e);
}
let name = source.name.clone();
let url = source.url.clone();
Ok(SourcePreview {
temp_dir,
spec: spec.to_string(),
items,
name,
url,
})
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct RegistrySuggestion {
pub spec: String,
pub name: String,
pub url: String,
pub alias: Option<String>,
}
#[allow(dead_code)] pub fn suggested_registry(paths: &Paths) -> Result<Vec<RegistrySuggestion>> {
let registry = Registry::load(paths)?;
let melded_urls: std::collections::HashSet<String> =
registry.sources.iter().map(|s| s.url.clone()).collect();
let mut seen_urls = melded_urls.clone();
let mut suggestions = Vec::new();
for source in ®istry.sources {
let clone_dir = source.clone_dir(paths);
let Ok(Some(mt)) = MindToml::load(&clone_dir) else {
continue;
};
let Some(discover) = &mt.discover else {
continue;
};
for entry in &discover.sources {
let Ok(parsed) = parse_spec(&entry.source) else {
continue;
};
if seen_urls.contains(&parsed.url) {
continue;
}
seen_urls.insert(parsed.url.clone());
suggestions.push(RegistrySuggestion {
spec: entry.source.clone(),
name: parsed.name.clone(),
url: parsed.url.clone(),
alias: entry.alias.clone(),
});
}
}
Ok(suggestions)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Paths;
use crate::source::Registry;
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_base() -> (PathBuf, PathBuf) {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-tui-preview-{}-{n}", std::process::id()));
let mind = base.join("mind");
crate::paths::mkdir_p(&mind).unwrap();
(base, mind)
}
fn cleanup(base: &std::path::Path) {
let _ = std::fs::remove_dir_all(base);
}
fn init_git_repo(dir: &std::path::Path) {
let run = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.expect("git");
};
run(&["-c", "init.defaultBranch=main", "init", "-q"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
}
fn make_source_repo(base: &std::path::Path) -> PathBuf {
let src = base.join("source-repo");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(src.join("skills/meld")).unwrap();
std::fs::write(
src.join("skills/meld/SKILL.md"),
"---\ndescription: meld skill\n---\n# meld\n",
)
.unwrap();
init_git_repo(&src);
Command::new("git")
.args(["add", "-A"])
.current_dir(&src)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-qm", "initial"])
.current_dir(&src)
.output()
.unwrap();
src
}
#[test]
fn preview_clones_and_scans_catalog() {
let (base, mind) = temp_base();
let src = make_source_repo(&base);
let paths = Paths {
mind_home: mind.clone(),
claude_home: base.join("claude"),
};
let prev = preview(&paths, src.to_str().unwrap());
assert!(prev.is_ok(), "preview should succeed: {:?}", prev.err());
let prev = prev.unwrap();
assert!(!prev.items.is_empty(), "preview should have catalog items");
assert!(
prev.items.iter().any(|it| it.name == "meld"),
"preview catalog should contain the 'meld' skill"
);
assert!(
prev.temp_dir.exists(),
"temp clone should exist while preview is live"
);
let temp = prev.temp_dir.clone();
drop(prev);
assert!(
!temp.exists(),
"temp clone should be removed after preview is dropped"
);
cleanup(&base);
}
#[test]
fn suggested_registry_empty_when_no_sources_melded() {
let (base, mind) = temp_base();
let paths = Paths {
mind_home: mind,
claude_home: base.join("claude"),
};
let suggestions = suggested_registry(&paths).unwrap();
assert!(
suggestions.is_empty(),
"no suggestions with no melded sources"
);
cleanup(&base);
}
#[test]
fn suggested_registry_excludes_already_melded() {
let (base, mind) = temp_base();
let nested_src = make_source_repo(&base);
let super_src = base.join("super-source");
std::fs::create_dir_all(&super_src).unwrap();
std::fs::write(super_src.join("README.md"), "# super\n").unwrap();
std::fs::write(
super_src.join("mind.toml"),
format!(
"[source]\ndescription = \"super\"\n\n[discover]\n[[discover.sources]]\nsource = \"{}\"\n",
nested_src.to_str().unwrap()
),
).unwrap();
init_git_repo(&super_src);
Command::new("git")
.args(["add", "-A"])
.current_dir(&super_src)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-qm", "init"])
.current_dir(&super_src)
.output()
.unwrap();
let paths = Paths {
mind_home: mind.clone(),
claude_home: base.join("claude"),
};
let mut super_source_parsed = parse_spec(super_src.to_str().unwrap()).unwrap();
super_source_parsed.commit = Some("abc".to_string());
let clone_dir = super_source_parsed.clone_dir(&paths);
crate::paths::mkdir_p(clone_dir.parent().unwrap()).unwrap();
Command::new("git")
.args([
"clone",
super_src.to_str().unwrap(),
clone_dir.to_str().unwrap(),
])
.output()
.unwrap();
let registry = Registry {
sources: vec![super_source_parsed.clone()],
};
registry.save(&paths).unwrap();
let suggestions = suggested_registry(&paths).unwrap();
let has_nested = suggestions.iter().any(|s| {
s.url
.contains(nested_src.file_name().unwrap().to_str().unwrap())
});
assert!(
has_nested,
"nested source should be suggested when not yet melded: {suggestions:?}"
);
if let Ok(n) = parse_spec(nested_src.to_str().unwrap()) {
let mut new_reg = Registry::load(&paths).unwrap();
new_reg.sources.push(n);
new_reg.save(&paths).unwrap();
}
let suggestions2 = suggested_registry(&paths).unwrap();
let nested_url = parse_spec(nested_src.to_str().unwrap()).unwrap().url;
let still_has = suggestions2.iter().any(|s| s.url == nested_url);
assert!(
!still_has,
"a source that is now melded must be excluded from suggestions (dedup by URL): {suggestions2:?}"
);
cleanup(&base);
}
#[test]
fn preview_invalid_spec_returns_error() {
let (base, mind) = temp_base();
let paths = Paths {
mind_home: mind,
claude_home: base.join("claude"),
};
let result = preview(&paths, "not-a-valid/url-that-has-no-slash");
assert!(result.is_err(), "invalid spec should return an error");
cleanup(&base);
}
#[test]
fn preview_temp_names_are_unique_for_same_repo() {
let pid = std::process::id();
let name1 = preview_temp_name("agents", pid, 0);
let name2 = preview_temp_name("agents", pid, 1);
assert_ne!(
name1, name2,
"successive preview temp names for the same repo must differ: {name1} vs {name2}"
);
assert!(
name1.contains("agents"),
"name1 must contain repo name: {name1}"
);
assert!(
name2.contains("agents"),
"name2 must contain repo name: {name2}"
);
}
#[test]
fn preview_nonce_advances_across_calls() {
let n1 = PREVIEW_NONCE.fetch_add(1, Ordering::SeqCst);
let n2 = PREVIEW_NONCE.fetch_add(1, Ordering::SeqCst);
assert!(n2 > n1, "PREVIEW_NONCE must advance: got {n1} then {n2}");
let pid = std::process::id();
assert_ne!(
preview_temp_name("agents", pid, n1),
preview_temp_name("agents", pid, n2),
"names built from consecutive nonces must differ"
);
}
}