diff --git a/src/config/bundles.rs b/src/config/bundles.rs new file mode 100644 index 0000000..255e85f --- /dev/null +++ b/src/config/bundles.rs @@ -0,0 +1,903 @@ +use super::install_remote::{ + canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name, +}; +use super::paths; +use crate::function::write_file_atomic; + +use anyhow::{Context, Result, bail}; +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum FileAction { + New, + Replaced, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum McpAction { + Added, + Replaced, + Renamed, + Transferred, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct FileRecord { + /// Relative to the config dir. + pub(crate) path: String, + pub(crate) category: String, + /// Content hash at install time; a later mismatch means the user modified the file. + pub(crate) sha256: String, + pub(crate) action: FileAction, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct McpServerRecord { + pub(crate) name: String, + pub(crate) action: McpAction, + pub(crate) renamed_to: Option, +} + +impl McpServerRecord { + /// The key this entry actually occupies in mcp.json (the rename target, if any). + pub(crate) fn effective_key(&self) -> &str { + self.renamed_to.as_deref().unwrap_or(&self.name) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct BundleRecord { + pub(crate) source: String, + #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")] + pub(crate) git_ref: Option, + pub(crate) commit: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, + pub(crate) installed_at: String, + #[serde(default)] + pub(crate) files: Vec, + #[serde(default)] + pub(crate) mcp_servers: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct InstallMetadata { + pub(crate) source: String, + pub(crate) git_ref: Option, + pub(crate) commit: String, + pub(crate) version: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ResolvedBundleName { + pub(crate) name: String, + /// The unqualified name this install asked for, when it had to be owner-qualified. + pub(crate) qualified_from: Option, + /// The record key this source was previously tracked under, when it changed. + pub(crate) migrated_from: Option, + /// Source URL of the different-source bundle that already holds the unqualified name. + pub(crate) same_name_other_source: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct StoreContents { + #[serde(default)] + bundles: BTreeMap, +} + +#[derive(Serialize)] +struct StoreContentsRef<'a> { + bundles: &'a BTreeMap, +} + +#[derive(Debug)] +pub(crate) struct BundleStore { + path: PathBuf, + bundles: BTreeMap, +} + +impl BundleStore { + pub(crate) fn load() -> Result { + Self::load_from(paths::installed_bundles_file()) + } + + /// A corrupt store is an error, never an empty store: treating it as empty + /// would let a reinstall re-acquire ownership over files the user may have + /// modified since. + pub(crate) fn load_from(path: PathBuf) -> Result { + if !path.exists() { + return Ok(Self { + path, + bundles: BTreeMap::new(), + }); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| { + format!( + "failed to parse {}; refusing to treat it as empty. \ + Fix or remove the file to continue", + path.display() + ) + })?; + Ok(Self { + path, + bundles: contents.bundles, + }) + } + + pub(crate) fn save(&self) -> Result<()> { + let content = serde_yaml::to_string(&StoreContentsRef { + bundles: &self.bundles, + }) + .context("failed to serialize the installed-bundles store")?; + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + write_file_atomic(&self.path, &content, None) + .with_context(|| format!("failed to write {}", self.path.display())) + } + + pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> { + self.bundles.get(name) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.bundles + .iter() + .map(|(name, record)| (name.as_str(), record)) + } + + pub(crate) fn bundle_names(&self) -> Vec<&str> { + self.bundles.keys().map(String::as_str).collect() + } + + /// Look up the record installed from `url`, comparing canonical source URLs + /// so https/scp/`.git` spellings of the same remote all match. + pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> { + let canonical = canonical_source_url(url); + self.bundles + .iter() + .find(|(_, record)| canonical_source_url(&record.source) == canonical) + .map(|(name, record)| (name.as_str(), record)) + } + + /// Decide the record key for an install from `url`, matching by canonical + /// source URL first and name second. If the URL is already tracked under a + /// different key (manifest name added, renamed, or removed since install), + /// the existing record is migrated to the new key — the same URL never gets + /// a second record. A name held by a different-source bundle is + /// owner-qualified instead. Both cases print a notice. + pub(crate) fn resolve_bundle_name( + &mut self, + url: &str, + manifest_name: Option<&str>, + ) -> Result { + let canonical = canonical_source_url(url); + let existing_key = self + .bundles + .iter() + .find(|(_, record)| canonical_source_url(&record.source) == canonical) + .map(|(name, _)| name.clone()); + + let base = match manifest_name { + Some(name) => { + validate_bundle_name(name)?; + name.to_string() + } + None => { + let slug = sanitize_name_segment(&repo_name_slug(url)); + if slug.is_empty() { + bail!( + "cannot derive a bundle name from '{url}'; \ + add a coyote-bundle.yaml manifest with a name" + ); + } + slug + } + }; + + let mut resolved = ResolvedBundleName { + name: base.clone(), + qualified_from: None, + migrated_from: None, + same_name_other_source: None, + }; + + if let Some(other_source) = self.source_of_other_bundle(&base, &canonical) { + if base.contains('/') { + bail!( + "bundle name '{base}' is already used by an install from \ + '{other_source}' and cannot be qualified further; \ + uninstall it or pick a different manifest name" + ); + } + let owner = owner_qualifier(url) + .map(|owner| sanitize_name_segment(&owner)) + .filter(|owner| !owner.is_empty()); + let Some(owner) = owner else { + bail!( + "bundle name '{base}' is already used by an install from \ + '{other_source}', and no owner qualifier can be derived from '{url}'" + ); + }; + let qualified = format!("{owner}/{base}"); + if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) { + bail!( + "bundle names '{base}' and '{qualified}' are both used by installs \ + from other sources ('{other_source}', '{source}'); \ + uninstall one or pick a different manifest name" + ); + } + resolved.name = qualified; + resolved.qualified_from = Some(base); + resolved.same_name_other_source = Some(other_source); + } + + let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str()); + if let Some(old_key) = existing_key + && !already_recorded + { + let record = self + .bundles + .remove(&old_key) + .expect("existing_key was found in the map"); + self.bundles.insert(resolved.name.clone(), record); + println!( + "Bundle '{old_key}' from {url} is now tracked as '{}'.", + resolved.name + ); + resolved.migrated_from = Some(old_key); + self.save()?; + } + + if let (Some(from), false) = (&resolved.qualified_from, already_recorded) { + let other = resolved + .same_name_other_source + .as_deref() + .unwrap_or_default(); + println!( + "Bundle name '{from}' is already used by an install from '{other}'; \ + tracking this install as '{}'.", + resolved.name + ); + } + + Ok(resolved) + } + + /// Create or update the record's metadata and persist it. Repeated installs + /// of the same bundle (e.g. with different filters) merge into one record: + /// metadata is refreshed, files accumulate, and the original install time + /// is kept. + pub(crate) fn upsert_bundle(&mut self, name: &str, metadata: InstallMetadata) -> Result<()> { + match self.bundles.get_mut(name) { + Some(record) => { + record.source = metadata.source; + record.git_ref = metadata.git_ref; + record.commit = metadata.commit; + record.version = metadata.version; + } + None => { + self.bundles.insert( + name.to_string(), + BundleRecord { + source: metadata.source, + git_ref: metadata.git_ref, + commit: metadata.commit, + version: metadata.version, + installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + files: Vec::new(), + mcp_servers: Vec::new(), + }, + ); + } + } + self.save() + } + + /// Record one written file and persist immediately, so an install aborted + /// partway through still has provenance for everything already on disk. + /// A path owned by another bundle transfers to `bundle`. + pub(crate) fn record_file(&mut self, bundle: &str, file: FileRecord) -> Result<()> { + self.ensure_bundle_exists(bundle)?; + for (name, record) in self.bundles.iter_mut() { + if name != bundle { + record.files.retain(|owned| owned.path != file.path); + } + } + let record = self + .bundles + .get_mut(bundle) + .expect("bundle existence checked above"); + record.files.retain(|owned| owned.path != file.path); + record.files.push(file); + self.save() + } + + /// Record the mcp.json entries an install wrote, in one persisted flush. + /// An entry whose key another bundle owns transfers to `bundle`: the old + /// owner drops it, and a `replaced` action is upgraded to `transferred` + /// (removable at uninstall — plain `replaced` marks a pre-existing user + /// entry that uninstall must never delete). + pub(crate) fn record_mcp_servers( + &mut self, + bundle: &str, + entries: Vec, + ) -> Result<()> { + self.ensure_bundle_exists(bundle)?; + for mut entry in entries { + let key = entry.effective_key().to_string(); + let mut previously_owned = false; + for (name, record) in self.bundles.iter_mut() { + if name == bundle { + continue; + } + let before = record.mcp_servers.len(); + record + .mcp_servers + .retain(|owned| owned.effective_key() != key); + previously_owned |= record.mcp_servers.len() != before; + } + if previously_owned && entry.action == McpAction::Replaced { + entry.action = McpAction::Transferred; + } + let record = self + .bundles + .get_mut(bundle) + .expect("bundle existence checked above"); + record + .mcp_servers + .retain(|owned| owned.effective_key() != key); + record.mcp_servers.push(entry); + } + self.save() + } + + fn ensure_bundle_exists(&self, bundle: &str) -> Result<()> { + if !self.bundles.contains_key(bundle) { + bail!( + "no installed bundle named '{bundle}' (installed: {})", + if self.bundles.is_empty() { + "none".to_string() + } else { + self.bundle_names().join(", ") + } + ); + } + Ok(()) + } + + fn source_of_other_bundle(&self, name: &str, canonical: &str) -> Option { + self.bundles + .get(name) + .filter(|record| canonical_source_url(&record.source) != canonical) + .map(|record| record.source.clone()) + } +} + +pub(crate) fn hash_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +pub(crate) fn hash_file(path: &Path) -> Result { + let bytes = + fs::read(path).with_context(|| format!("failed to read {} for hashing", path.display()))?; + Ok(hash_bytes(&bytes)) +} + +fn sanitize_name_segment(segment: &str) -> String { + segment + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::{get_env_name, temp_file}; + use serial_test::serial; + use std::env; + use std::ffi::OsString; + + struct TempStoreDir(PathBuf); + + impl TempStoreDir { + fn new(label: &str) -> Self { + let dir = temp_file(label, ""); + fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn store_path(&self) -> PathBuf { + self.0.join("installed-bundles.yaml") + } + + fn store(&self) -> BundleStore { + BundleStore::load_from(self.store_path()).unwrap() + } + } + + impl Drop for TempStoreDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn metadata(source: &str, commit: &str) -> InstallMetadata { + InstallMetadata { + source: source.to_string(), + git_ref: None, + commit: commit.to_string(), + version: None, + } + } + + fn file_record(path: &str, contents: &str) -> FileRecord { + FileRecord { + path: path.to_string(), + category: "macros".to_string(), + sha256: hash_bytes(contents.as_bytes()), + action: FileAction::New, + } + } + + fn mcp_record(name: &str, action: McpAction, renamed_to: Option<&str>) -> McpServerRecord { + McpServerRecord { + name: name.to_string(), + action, + renamed_to: renamed_to.map(str::to_string), + } + } + + #[test] + fn load_missing_file_yields_empty_store() { + let dir = TempStoreDir::new("bundles-empty"); + + let store = dir.store(); + + assert!(store.bundle_names().is_empty()); + } + + #[test] + fn corrupt_store_fails_closed() { + let dir = TempStoreDir::new("bundles-corrupt"); + fs::write(dir.store_path(), "bundles:\n - this is not a map\n").unwrap(); + + let result = BundleStore::load_from(dir.store_path()); + + let message = format!("{:#}", result.unwrap_err()); + assert!( + message.contains("refusing to treat it as empty"), + "{message}" + ); + } + + #[test] + fn save_and_reload_roundtrip() { + let dir = TempStoreDir::new("bundles-roundtrip"); + let mut store = dir.store(); + store + .upsert_bundle( + "omc", + InstallMetadata { + source: "https://github.com/x/omc".to_string(), + git_ref: Some("main".to_string()), + commit: "abc123".to_string(), + version: Some("1.4.0".to_string()), + }, + ) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + store + .record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)]) + .unwrap(); + + let raw = fs::read_to_string(dir.store_path()).unwrap(); + let reloaded = dir.store(); + + assert!(raw.contains("ref: main"), "{raw}"); + assert!(!raw.contains("git_ref"), "{raw}"); + assert!(raw.contains("action: added"), "{raw}"); + let record = reloaded.get("omc").unwrap(); + assert_eq!(record.git_ref.as_deref(), Some("main")); + assert_eq!(record.version.as_deref(), Some("1.4.0")); + assert_eq!(record.files.len(), 1); + assert_eq!(record.mcp_servers.len(), 1); + } + + #[test] + fn files_recorded_before_an_abort_survive_it() { + let dir = TempStoreDir::new("bundles-abort"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + store + .record_file("omc", file_record("skills/b.md", "b")) + .unwrap(); + drop(store); + + let reloaded = dir.store(); + let paths: Vec<&str> = reloaded + .get("omc") + .unwrap() + .files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(paths, vec!["macros/a.yaml", "skills/b.md"]); + } + + #[test] + fn recording_the_same_path_twice_updates_in_place() { + let dir = TempStoreDir::new("bundles-dedupe"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + store + .record_file("omc", file_record("macros/a.yaml", "v1")) + .unwrap(); + let mut updated = file_record("macros/a.yaml", "v2"); + updated.action = FileAction::Replaced; + store.record_file("omc", updated).unwrap(); + + let record = store.get("omc").unwrap(); + assert_eq!(record.files.len(), 1); + assert_eq!(record.files[0].sha256, hash_bytes(b"v2")); + assert_eq!(record.files[0].action, FileAction::Replaced); + } + + #[test] + fn overwritten_file_transfers_ownership() { + let dir = TempStoreDir::new("bundles-transfer"); + let mut store = dir.store(); + store + .upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123")) + .unwrap(); + store + .upsert_bundle("beta", metadata("https://github.com/b/beta", "def456")) + .unwrap(); + store + .record_file("alpha", file_record("macros/shared.yaml", "a")) + .unwrap(); + + let mut taken = file_record("macros/shared.yaml", "b"); + taken.action = FileAction::Replaced; + store.record_file("beta", taken).unwrap(); + + let reloaded = dir.store(); + assert!(reloaded.get("alpha").unwrap().files.is_empty()); + let beta_files = &reloaded.get("beta").unwrap().files; + assert_eq!(beta_files.len(), 1); + assert_eq!(beta_files[0].path, "macros/shared.yaml"); + } + + #[test] + fn overwritten_mcp_entry_transfers_ownership_as_transferred() { + let dir = TempStoreDir::new("bundles-mcp-transfer"); + let mut store = dir.store(); + store + .upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123")) + .unwrap(); + store + .upsert_bundle("beta", metadata("https://github.com/b/beta", "def456")) + .unwrap(); + store + .record_mcp_servers("alpha", vec![mcp_record("srv", McpAction::Added, None)]) + .unwrap(); + + store + .record_mcp_servers("beta", vec![mcp_record("srv", McpAction::Replaced, None)]) + .unwrap(); + + let reloaded = dir.store(); + assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty()); + let beta_servers = &reloaded.get("beta").unwrap().mcp_servers; + assert_eq!(beta_servers.len(), 1); + assert_eq!(beta_servers[0].action, McpAction::Transferred); + } + + #[test] + fn mcp_replacement_of_unowned_entry_stays_replaced() { + let dir = TempStoreDir::new("bundles-mcp-replaced"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + store + .record_mcp_servers( + "omc", + vec![mcp_record("user-srv", McpAction::Replaced, None)], + ) + .unwrap(); + + assert_eq!( + store.get("omc").unwrap().mcp_servers[0].action, + McpAction::Replaced + ); + } + + #[test] + fn mcp_transfer_matches_renamed_entries_by_effective_key() { + let dir = TempStoreDir::new("bundles-mcp-renamed"); + let mut store = dir.store(); + store + .upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123")) + .unwrap(); + store + .upsert_bundle("beta", metadata("https://github.com/b/beta", "def456")) + .unwrap(); + store + .record_mcp_servers( + "alpha", + vec![mcp_record("srv", McpAction::Renamed, Some("srv-remote"))], + ) + .unwrap(); + + store + .record_mcp_servers( + "beta", + vec![mcp_record("srv-remote", McpAction::Replaced, None)], + ) + .unwrap(); + + let reloaded = dir.store(); + assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty()); + assert_eq!( + reloaded.get("beta").unwrap().mcp_servers[0].action, + McpAction::Transferred + ); + } + + #[test] + fn resolve_same_url_same_name_is_an_update() { + let dir = TempStoreDir::new("bundles-resolve-update"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("git@github.com:X/omc.git", None) + .unwrap(); + + assert_eq!(resolved.name, "omc"); + assert_eq!(resolved.migrated_from, None); + assert_eq!(resolved.qualified_from, None); + } + + #[test] + fn resolve_migrates_record_when_identity_changes() { + let dir = TempStoreDir::new("bundles-migrate"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("git@github.com:x/omc.git", Some("oh-my-coyote")) + .unwrap(); + + assert_eq!(resolved.name, "oh-my-coyote"); + assert_eq!(resolved.migrated_from.as_deref(), Some("omc")); + let reloaded = dir.store(); + assert!(reloaded.get("omc").is_none()); + assert_eq!(reloaded.get("oh-my-coyote").unwrap().files.len(), 1); + } + + #[test] + fn resolve_qualifies_colliding_name_from_https_source() { + let dir = TempStoreDir::new("bundles-qualify-https"); + let mut store = dir.store(); + store + .upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("https://gitlab.com/b/repo.git", None) + .unwrap(); + + assert_eq!(resolved.name, "b/repo"); + assert_eq!(resolved.qualified_from.as_deref(), Some("repo")); + assert_eq!( + resolved.same_name_other_source.as_deref(), + Some("https://github.com/a/repo") + ); + } + + #[test] + fn resolve_qualifies_colliding_name_from_scp_source() { + let dir = TempStoreDir::new("bundles-qualify-scp"); + let mut store = dir.store(); + store + .upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("git@bitbucket.org:c/repo.git", None) + .unwrap(); + + assert_eq!(resolved.name, "c/repo"); + assert_eq!(resolved.qualified_from.as_deref(), Some("repo")); + } + + #[test] + fn resolve_of_already_qualified_record_is_stable() { + let dir = TempStoreDir::new("bundles-qualify-stable"); + let mut store = dir.store(); + store + .upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123")) + .unwrap(); + store + .upsert_bundle("b/repo", metadata("https://gitlab.com/b/repo", "def456")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("https://gitlab.com/b/repo.git", None) + .unwrap(); + + assert_eq!(resolved.name, "b/repo"); + assert_eq!(resolved.migrated_from, None); + } + + #[test] + fn resolve_sanitizes_derived_names() { + let dir = TempStoreDir::new("bundles-sanitize"); + let mut store = dir.store(); + + let resolved = store + .resolve_bundle_name("https://github.com/vercel/next.js.git", None) + .unwrap(); + + assert_eq!(resolved.name, "next-js"); + } + + #[test] + fn resolve_rejects_invalid_manifest_names() { + let dir = TempStoreDir::new("bundles-invalid-name"); + let mut store = dir.store(); + + let result = store.resolve_bundle_name("https://github.com/x/repo", Some("a/b/c")); + + assert!(result.is_err()); + } + + #[test] + fn upsert_merges_metadata_and_preserves_files_and_install_time() { + let dir = TempStoreDir::new("bundles-merge"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + let installed_at = store.get("omc").unwrap().installed_at.clone(); + + store + .upsert_bundle("omc", metadata("https://github.com/x/omc.git", "def456")) + .unwrap(); + + let record = store.get("omc").unwrap(); + assert_eq!(record.commit, "def456"); + assert_eq!(record.source, "https://github.com/x/omc.git"); + assert_eq!(record.installed_at, installed_at); + assert_eq!(record.files.len(), 1); + } + + #[test] + fn recording_against_unknown_bundle_fails() { + let dir = TempStoreDir::new("bundles-unknown"); + let mut store = dir.store(); + + let result = store.record_file("ghost", file_record("macros/a.yaml", "a")); + + assert!(result.unwrap_err().to_string().contains("ghost")); + } + + #[cfg(unix)] + #[test] + fn failed_save_preserves_the_existing_store() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempStoreDir::new("bundles-atomic"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + let before = fs::read_to_string(dir.store_path()).unwrap(); + + fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o555)).unwrap(); + let probe = dir.0.join(".write-probe"); + if fs::write(&probe, "x").is_ok() { + // A privileged user bypasses permission bits; the failure path + // cannot be provoked this way. + let _ = fs::remove_file(&probe); + fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + + let result = store.record_file("omc", file_record("macros/a.yaml", "a")); + fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!(result.is_err()); + assert_eq!(fs::read_to_string(dir.store_path()).unwrap(), before); + let reloaded = dir.store(); + assert!(reloaded.get("omc").unwrap().files.is_empty()); + } + + #[test] + fn hash_helpers_are_stable() { + let dir = TempStoreDir::new("bundles-hash"); + let path = dir.0.join("artifact.yaml"); + fs::write(&path, "hello").unwrap(); + + assert_eq!( + hash_bytes(b"hello"), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + assert_eq!(hash_file(&path).unwrap(), hash_bytes(b"hello")); + assert_ne!(hash_bytes(b"hello"), hash_bytes(b"hello!")); + } + + #[test] + #[serial] + fn default_store_path_follows_the_config_dir() { + let dir = TempStoreDir::new("bundles-env"); + let key = get_env_name("config_dir"); + let previous: Option = env::var_os(&key); + unsafe { + env::set_var(&key, &dir.0); + } + + let result = (|| -> Result<()> { + let mut store = BundleStore::load()?; + assert!(store.bundle_names().is_empty()); + store.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))?; + let reloaded = BundleStore::load()?; + assert!(reloaded.get("omc").is_some()); + assert!(dir.store_path().is_file()); + Ok(()) + })(); + + unsafe { + match &previous { + Some(value) => env::set_var(&key, value), + None => env::remove_var(&key), + } + } + result.unwrap(); + } +} diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 2ae5950..5dc3464 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -374,14 +374,12 @@ fn strip_git_suffix(segment: &str) -> &str { } } -#[allow(dead_code)] pub(crate) fn repo_name_slug(url: &str) -> String { let (_, path) = split_host_and_path(url); let last = path.rsplit('/').next().unwrap_or(""); strip_git_suffix(last).to_string() } -#[allow(dead_code)] pub(crate) fn owner_qualifier(url: &str) -> Option { let (host, path) = split_host_and_path(url); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); @@ -407,7 +405,6 @@ fn sanitize_host(host: &str) -> String { .to_string() } -#[allow(dead_code)] pub(crate) fn canonical_source_url(url: &str) -> String { let (host, path) = split_host_and_path(url); let mut path = path.to_ascii_lowercase(); diff --git a/src/config/mod.rs b/src/config/mod.rs index ea90e76..5029016 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,8 @@ mod agent; mod app_config; mod app_state; +#[allow(dead_code)] +mod bundles; mod input; mod install_remote; pub(crate) mod instructions; @@ -155,6 +157,7 @@ const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml"; +const INSTALLED_BUNDLES_FILE_NAME: &str = "installed-bundles.yaml"; const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits"; const GIT_DIR_NAME: &str = ".git"; const GITIGNORE_FILE_NAME: &str = ".gitignore"; diff --git a/src/config/paths.rs b/src/config/paths.rs index 6a0ecfe..0e2154e 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -2,10 +2,10 @@ use super::role::Role; use super::{ AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, - GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, - MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, - SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, - SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, + GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, INSTALLED_BUNDLES_FILE_NAME, + MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, + RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, + SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, }; use crate::client::ProviderModels; use crate::config::REPL_HISTORY_DIR_NAME; @@ -169,6 +169,10 @@ pub fn config_file() -> PathBuf { } } +pub fn installed_bundles_file() -> PathBuf { + local_dir(INSTALLED_BUNDLES_FILE_NAME) +} + pub fn roles_dir() -> PathBuf { match env::var(get_env_name("roles_dir")) { Ok(value) => PathBuf::from(value),