use anyhow::Context;
use std::path::{Path, PathBuf};
pub mod fs;
pub mod manifest_utils;
pub mod path_validation;
pub mod platform;
pub mod progress;
pub mod security;
pub use fs::{
atomic_write, compare_file_times, copy_dir, create_temp_file, ensure_dir,
file_exists_and_readable, get_modified_time, normalize_path, read_json_file, read_text_file,
read_toml_file, read_yaml_file, safe_write, write_json_file, write_text_file, write_toml_file,
write_yaml_file,
};
pub use manifest_utils::{
load_and_validate_manifest, load_project_manifest, manifest_exists, manifest_path,
};
pub use path_validation::{
ensure_directory_exists, ensure_within_directory, find_project_root, safe_canonicalize,
safe_relative_path, sanitize_file_name, validate_no_traversal, validate_project_path,
validate_resource_path,
};
pub use platform::{
compute_relative_install_path, get_git_command, get_home_dir, is_windows,
normalize_path_for_storage, resolve_path,
};
pub use progress::{InstallationPhase, MultiPhaseProgress, collect_dependency_names};
pub fn canonicalize_json(value: &serde_json::Value) -> anyhow::Result<String> {
serialize_json_canonically(value)
}
pub static EMPTY_VARIANT_INPUTS_HASH: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| {
compute_variant_inputs_hash(&serde_json::json!({}))
.expect("Failed to compute hash of empty JSON object")
});
pub fn compute_variant_inputs_hash(variant_inputs: &serde_json::Value) -> anyhow::Result<String> {
use sha2::{Digest, Sha256};
let serialized = serialize_json_canonically(variant_inputs)?;
let hash_result = Sha256::digest(serialized.as_bytes());
Ok(format!("sha256:{}", hex::encode(hash_result)))
}
fn serialize_json_canonically(value: &serde_json::Value) -> anyhow::Result<String> {
match value {
serde_json::Value::Object(map) => {
let mut sorted_keys: Vec<_> = map.keys().collect();
sorted_keys.sort();
let mut result = String::from("{");
for (i, key) in sorted_keys.iter().enumerate() {
if i > 0 {
result.push(',');
}
result.push_str(&serde_json::to_string(key)?);
result.push(':');
let val = map.get(*key).unwrap();
result.push_str(&serialize_json_canonically(val)?);
}
result.push('}');
Ok(result)
}
serde_json::Value::Array(arr) => {
let mut result = String::from("[");
for (i, item) in arr.iter().enumerate() {
if i > 0 {
result.push(',');
}
result.push_str(&serialize_json_canonically(item)?);
}
result.push(']');
Ok(result)
}
_ => serde_json::to_string(value).context("Failed to serialize JSON value"),
}
}
pub fn generate_backup_path(config_path: &Path, tool_name: &str) -> anyhow::Result<PathBuf> {
use anyhow::{Context, anyhow};
let project_root = find_project_root(config_path)
.with_context(|| format!("Failed to find project root from: {}", config_path.display()))?;
let backup_dir = project_root.join(".agpm").join("backups").join(tool_name);
let filename = config_path
.file_name()
.ok_or_else(|| anyhow!("Invalid config path: {}", config_path.display()))?;
Ok(backup_dir.join(filename))
}
#[must_use]
pub fn is_local_path(url: &str) -> bool {
if url.starts_with("file://") {
return false;
}
if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
return true;
}
if url.len() >= 2 {
let chars: Vec<char> = url.chars().collect();
if chars[0].is_ascii_alphabetic() && chars[1] == ':' {
return true;
}
}
if url.starts_with("//") || url.starts_with("\\\\") {
return true;
}
false
}
#[must_use]
pub fn is_git_url(url: &str) -> bool {
!is_local_path(url)
}
pub fn resolve_file_relative_path(
parent_file_path: &std::path::Path,
relative_path: &str,
) -> anyhow::Result<std::path::PathBuf> {
use anyhow::{Context, anyhow};
if !relative_path.starts_with("./") && !relative_path.starts_with("../") {
return Err(anyhow!(
"Transitive dependency path must start with './' or '../': {}",
relative_path
));
}
let parent_dir = parent_file_path
.parent()
.ok_or_else(|| anyhow!("Parent file has no directory: {}", parent_file_path.display()))?;
let resolved = parent_dir.join(relative_path);
resolved.canonicalize().with_context(|| {
format!(
"Transitive dependency does not exist: {} (resolved from '{}' relative to '{}')",
resolved.display(),
relative_path,
parent_dir.display()
)
})
}
pub fn resolve_path_relative_to_manifest(
manifest_dir: &std::path::Path,
rel_path: &str,
) -> anyhow::Result<std::path::PathBuf> {
use anyhow::Context;
let expanded = shellexpand::full(rel_path)
.with_context(|| format!("Failed to expand path: {}", rel_path))?;
let path = std::path::PathBuf::from(expanded.as_ref());
let resolved = if path.is_absolute() {
path
} else {
manifest_dir.join(path)
};
resolved.canonicalize().with_context(|| {
format!(
"Path does not exist: {} (resolved from manifest dir '{}')",
resolved.display(),
manifest_dir.display()
)
})
}
pub fn compute_relative_path(base: &std::path::Path, target: &std::path::Path) -> String {
use std::path::Component;
if let Ok(relative) = target.strip_prefix(base) {
return normalize_path_for_storage(relative);
}
let base_components: Vec<_> = base.components().collect();
let target_components: Vec<_> = target.components().collect();
let mut common_prefix_len = 0;
for (b, t) in base_components.iter().zip(target_components.iter()) {
if b == t {
common_prefix_len += 1;
} else {
break;
}
}
let base_remainder = &base_components[common_prefix_len..];
let target_remainder = &target_components[common_prefix_len..];
let mut result = std::path::PathBuf::new();
for _ in base_remainder {
result.push("..");
}
for component in target_remainder {
if let Component::Normal(c) = component {
result.push(c);
}
}
normalize_path_for_storage(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_compute_relative_path_inside_base() {
let base = Path::new("/project");
let target = Path::new("/project/agents/helper.md");
let result = compute_relative_path(base, target);
assert_eq!(result, "agents/helper.md");
}
#[test]
fn test_compute_relative_path_outside_base() {
let base = Path::new("/project");
let target = Path::new("/shared/utils.md");
let result = compute_relative_path(base, target);
assert_eq!(result, "../shared/utils.md");
}
#[test]
fn test_compute_relative_path_multiple_levels_up() {
let base = Path::new("/project/subdir");
let target = Path::new("/other/file.md");
let result = compute_relative_path(base, target);
assert_eq!(result, "../../other/file.md");
}
#[test]
fn test_compute_relative_path_same_directory() {
let base = Path::new("/project");
let target = Path::new("/project");
let result = compute_relative_path(base, target);
assert_eq!(result, "");
}
#[test]
fn test_compute_relative_path_nested() {
let base = Path::new("/a/b/c");
let target = Path::new("/a/d/e/f.md");
let result = compute_relative_path(base, target);
assert_eq!(result, "../../d/e/f.md");
}
}
#[cfg(test)]
mod backup_path_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_generate_backup_path() {
use crate::utils::platform::normalize_path_for_storage;
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
let config_dir = project_root.join(".claude");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("settings.local.json");
fs::write(&config_path, "{}").unwrap();
let backup_path = generate_backup_path(&config_path, "claude-code").unwrap();
let project_root = std::fs::canonicalize(project_root).unwrap();
assert!(backup_path.starts_with(project_root));
let normalized_backup = normalize_path_for_storage(&backup_path);
assert!(normalized_backup.contains(".agpm/backups/claude-code/"));
assert!(normalized_backup.ends_with("settings.local.json"));
}
#[test]
fn test_generate_backup_path_fails_when_no_project_root() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("orphan-config.json");
fs::write(&config_path, "{}").unwrap();
let result = generate_backup_path(&config_path, "claude-code");
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Failed to find project root"));
assert!(error_msg.contains("agpm.toml") || error_msg.contains("project root"));
}
#[test]
fn test_generate_backup_path_with_nested_config() {
use crate::utils::platform::normalize_path_for_storage;
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
let config_path = project_root.join(".claude/subdir/settings.local.json");
let backup_path = generate_backup_path(&config_path, "claude-code").unwrap();
let project_root = std::fs::canonicalize(project_root).unwrap();
assert!(backup_path.starts_with(project_root));
let normalized_backup = normalize_path_for_storage(&backup_path);
assert!(normalized_backup.contains(".agpm/backups/claude-code/"));
assert!(normalized_backup.ends_with("settings.local.json"));
assert!(!normalized_backup.contains("subdir"));
}
#[test]
fn test_generate_backup_path_different_tools() {
use crate::utils::platform::normalize_path_for_storage;
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
let config_path = project_root.join(".mcp.json");
let claude_backup = generate_backup_path(&config_path, "claude-code").unwrap();
let open_backup = generate_backup_path(&config_path, "opencode").unwrap();
let custom_backup = generate_backup_path(&config_path, "my-tool").unwrap();
let normalized_claude = normalize_path_for_storage(&claude_backup);
let normalized_open = normalize_path_for_storage(&open_backup);
let normalized_custom = normalize_path_for_storage(&custom_backup);
assert!(normalized_claude.contains(".agpm/backups/claude-code/"));
assert!(normalized_open.contains(".agpm/backups/opencode/"));
assert!(normalized_custom.contains(".agpm/backups/my-tool/"));
assert!(normalized_claude.ends_with("mcp.json"));
assert!(normalized_open.ends_with("mcp.json"));
assert!(normalized_custom.ends_with("mcp.json"));
}
#[test]
fn test_generate_backup_path_invalid_config_path() {
let invalid_path = Path::new("/");
let result = generate_backup_path(invalid_path, "claude-code");
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Failed to find project root")
|| error_msg.contains("Invalid config path")
);
}
#[test]
fn test_backup_path_normalization_cross_platform() {
use crate::utils::platform::normalize_path_for_storage;
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("agpm.toml"), "[sources]\n").unwrap();
let unix_style = temp_dir.path().join(".claude").join("settings.local.json");
let direct_path = temp_dir.path().join(".claude/settings.local.json");
let backup1 = generate_backup_path(&unix_style, "claude-code").unwrap();
let backup2 = generate_backup_path(&direct_path, "claude-code").unwrap();
assert_eq!(backup1, backup2);
let backup_normalized = normalize_path_for_storage(&backup1);
assert!(backup_normalized.contains(".agpm/backups/claude-code/settings.local.json"));
}
#[test]
#[cfg(unix)]
fn test_backup_with_symlinked_config() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
let real_config = project_root.join("real-settings.json");
fs::write(&real_config, r#"{"test": "value"}"#).unwrap();
fs::create_dir_all(project_root.join(".claude")).unwrap();
let symlink_config = project_root.join(".claude/settings.local.json");
symlink(&real_config, &symlink_config).unwrap();
let backup_path = generate_backup_path(&symlink_config, "claude-code").unwrap();
let project_root = std::fs::canonicalize(project_root).unwrap();
assert!(backup_path.starts_with(project_root));
assert!(backup_path.to_str().unwrap().contains(".agpm/backups/claude-code/"));
assert!(backup_path.to_str().unwrap().ends_with("settings.local.json"));
}
#[test]
#[cfg(target_os = "windows")]
fn test_backup_with_long_windows_path() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
fs::write(project_root.join("agpm.toml"), "[sources]\n").unwrap();
let mut long_path = project_root.to_path_buf();
for _ in 0..10 {
long_path = long_path.join("very_long_directory_name_that_might_cause_issues");
}
fs::create_dir_all(&long_path).unwrap();
let config_path = long_path.join("settings.local.json");
fs::write(&config_path, "{}").unwrap();
let result = generate_backup_path(&config_path, "claude-code");
match result {
Ok(backup_path) => {
let canonical_root = std::fs::canonicalize(project_root)
.unwrap_or_else(|_| project_root.to_path_buf());
assert!(backup_path.starts_with(&canonical_root));
}
Err(err) => {
let error_msg = err.to_string();
assert!(error_msg.len() > 10);
}
}
}
}