use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use crate::error::MarsError;
use crate::hash;
use crate::lock::ItemKind;
use crate::sync::plan;
use crate::types::{ContentHash, DestPath, ItemName};
pub(crate) struct LocalItem {
pub kind: ItemKind,
pub name: ItemName,
pub source_path: PathBuf,
pub dest_rel: DestPath,
}
pub(crate) fn discover_local_items(project_root: &Path) -> Result<Vec<LocalItem>, MarsError> {
let mut items = Vec::new();
let agents_dir = project_root.join("agents");
if agents_dir.is_dir() {
for entry in std::fs::read_dir(&agents_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") && path.is_file() {
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
items.push(LocalItem {
kind: ItemKind::Agent,
name: ItemName::from(name.as_str()),
source_path: path.canonicalize().unwrap_or(path.clone()),
dest_rel: format!("agents/{}.md", name).into(),
});
}
}
}
let skills_dir = project_root.join("skills");
if skills_dir.is_dir() {
for entry in std::fs::read_dir(&skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() && path.join("SKILL.md").exists() {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
items.push(LocalItem {
kind: ItemKind::Skill,
name: ItemName::from(name.as_str()),
source_path: path.canonicalize().unwrap_or(path.clone()),
dest_rel: format!("skills/{}", name).into(),
});
}
}
}
Ok(items)
}
pub(crate) fn build_self_lock_items(
items: &[LocalItem],
) -> Result<Vec<crate::lock::SelfLockItem>, MarsError> {
let mut lock_items = Vec::with_capacity(items.len());
for item in items {
let source_checksum = ContentHash::from(hash::compute_hash(&item.source_path, item.kind)?);
lock_items.push(crate::lock::SelfLockItem {
dest_path: item.dest_rel.clone(),
kind: item.kind,
source_checksum,
});
}
Ok(lock_items)
}
pub(crate) fn inject_self_items(
config_has_package: bool,
project_root: &Path,
managed_root: &Path,
old_lock: &crate::lock::LockFile,
target_state: &mut crate::sync::target::TargetState,
sync_plan: &mut plan::SyncPlan,
) -> Result<HashSet<DestPath>, MarsError> {
let mut skipped_self_dests: HashSet<DestPath> = HashSet::new();
if config_has_package {
let self_items = discover_local_items(project_root)?;
for item in &self_items {
if target_state.items.contains_key(&item.dest_rel) {
let existing = &target_state.items[&item.dest_rel];
eprintln!(
"warning: local {} `{}` shadows dependency `{}` {} `{}`",
item.kind, item.name, existing.source_name, existing.id.kind, existing.id.name
);
let dest_rel = item.dest_rel.clone();
sync_plan
.actions
.retain(|a| !action_matches_dest(a, &dest_rel));
target_state.items.shift_remove(&item.dest_rel);
}
}
for item in &self_items {
let dest = managed_root.join(item.dest_rel.as_path());
if !old_lock.items.contains_key(&item.dest_rel) && dest.symlink_metadata().is_ok() {
eprintln!(
"warning: local {} `{}` collides with unmanaged path `{}` — leaving existing content untouched",
item.kind, item.name, item.dest_rel
);
skipped_self_dests.insert(item.dest_rel.clone());
continue;
}
let needs_update = match dest.symlink_metadata() {
Ok(meta) if meta.file_type().is_symlink() => {
let current_target = std::fs::read_link(&dest).ok();
let from_dir = dest.parent().unwrap();
let expected = pathdiff::diff_paths(&item.source_path, from_dir)
.unwrap_or_else(|| item.source_path.clone());
current_target.as_deref() != Some(expected.as_path())
}
Ok(_) => true, Err(_) => true, };
if needs_update {
sync_plan.actions.push(plan::PlannedAction::Symlink {
source_abs: item.source_path.clone(),
dest_rel: item.dest_rel.clone(),
kind: item.kind,
name: item.name.clone(),
});
}
}
let self_dest_set: std::collections::HashSet<_> =
self_items.iter().map(|i| &i.dest_rel).collect();
for (dest_path, locked_item) in &old_lock.items {
if locked_item.source.as_ref() == "_self" && !self_dest_set.contains(dest_path) {
sync_plan.actions.push(plan::PlannedAction::Remove {
locked: locked_item.clone(),
});
}
}
} else {
for (_, locked_item) in &old_lock.items {
if locked_item.source.as_ref() == "_self" {
sync_plan.actions.push(plan::PlannedAction::Remove {
locked: locked_item.clone(),
});
}
}
}
sync_plan.actions.retain(|action| {
if let plan::PlannedAction::Remove { locked } = action {
locked.source.as_ref() != "_self"
} else {
true
}
});
Ok(skipped_self_dests)
}
fn action_matches_dest(action: &plan::PlannedAction, dest: &DestPath) -> bool {
match action {
plan::PlannedAction::Install { target } | plan::PlannedAction::Overwrite { target } => {
&target.dest_path == dest
}
plan::PlannedAction::Skip { dest_path, .. }
| plan::PlannedAction::KeepLocal { dest_path, .. } => dest_path == dest,
plan::PlannedAction::Merge { target, .. } => &target.dest_path == dest,
plan::PlannedAction::Remove { locked } => &locked.dest_path == dest,
plan::PlannedAction::Symlink { dest_rel, .. } => dest_rel == dest,
}
}