use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::{fs, process};
use serde::Deserialize;
const PKG_DIR: &str = ".harn/packages";
const MANIFEST: &str = "harn.toml";
const LOCK_FILE: &str = "harn.lock";
#[derive(Debug, Deserialize)]
pub struct Manifest {
#[allow(dead_code)]
pub package: Option<PackageInfo>,
#[serde(default)]
pub dependencies: HashMap<String, Dependency>,
#[serde(default)]
pub mcp: Vec<McpServerConfig>,
#[serde(default)]
pub check: CheckConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct CheckConfig {
#[serde(default)]
pub strict: bool,
#[serde(default)]
pub strict_types: bool,
#[serde(default)]
pub disable_rules: Vec<String>,
#[serde(default)]
pub host_capabilities: HashMap<String, Vec<String>>,
#[serde(default, alias = "host_capabilities_file")]
pub host_capabilities_path: Option<String>,
#[serde(default)]
pub bundle_root: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct McpServerConfig {
pub name: String,
#[serde(default)]
pub transport: Option<String>,
#[serde(default)]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub url: String,
#[serde(default)]
pub auth_token: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default)]
pub scopes: Option<String>,
#[serde(default)]
pub protocol_version: Option<String>,
#[serde(default)]
pub proxy_server_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct PackageInfo {
pub name: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Dependency {
Table(DepTable),
Path(String),
}
#[derive(Debug, Clone, Deserialize)]
pub struct DepTable {
pub git: Option<String>,
pub tag: Option<String>,
pub path: Option<String>,
}
impl Dependency {
fn git_url(&self) -> Option<&str> {
match self {
Dependency::Table(t) => t.git.as_deref(),
Dependency::Path(_) => None,
}
}
fn tag(&self) -> Option<&str> {
match self {
Dependency::Table(t) => t.tag.as_deref(),
Dependency::Path(_) => None,
}
}
fn local_path(&self) -> Option<&str> {
match self {
Dependency::Table(t) => t.path.as_deref(),
Dependency::Path(p) => Some(p.as_str()),
}
}
}
#[derive(Debug, Default)]
struct LockFile {
entries: HashMap<String, LockEntry>,
}
#[derive(Debug, Clone)]
struct LockEntry {
git: Option<String>,
tag: Option<String>,
commit: Option<String>,
path: Option<String>,
}
impl LockFile {
fn load(path: &Path) -> Self {
let content = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return Self::default(),
};
let mut entries = HashMap::new();
let mut current_name: Option<String> = None;
let mut current = LockEntry {
git: None,
tag: None,
commit: None,
path: None,
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("[[package]]") {
if let Some(name) = current_name.take() {
entries.insert(name, current.clone());
}
current = LockEntry {
git: None,
tag: None,
commit: None,
path: None,
};
} else if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"name" => current_name = Some(value.to_string()),
"git" => current.git = Some(value.to_string()),
"tag" => current.tag = Some(value.to_string()),
"commit" => current.commit = Some(value.to_string()),
"path" => current.path = Some(value.to_string()),
_ => {}
}
}
}
if let Some(name) = current_name {
entries.insert(name, current);
}
LockFile { entries }
}
fn save(&self, path: &Path) {
let mut out =
String::from("# This file is auto-generated by `harn install`. Do not edit.\n\n");
let mut names: Vec<&String> = self.entries.keys().collect();
names.sort();
for name in names {
let entry = &self.entries[name];
out.push_str("[[package]]\n");
out.push_str(&format!("name = \"{name}\"\n"));
if let Some(git) = &entry.git {
out.push_str(&format!("git = \"{git}\"\n"));
}
if let Some(tag) = &entry.tag {
out.push_str(&format!("tag = \"{tag}\"\n"));
}
if let Some(commit) = &entry.commit {
out.push_str(&format!("commit = \"{commit}\"\n"));
}
if let Some(path) = &entry.path {
out.push_str(&format!("path = \"{path}\"\n"));
}
out.push('\n');
}
if let Err(e) = fs::write(path, &out) {
eprintln!("Failed to write lock file: {e}");
}
}
}
pub fn read_manifest() -> Manifest {
let content = match fs::read_to_string(MANIFEST) {
Ok(s) => s,
Err(_) => {
eprintln!("No harn.toml found in current directory.");
eprintln!("Create one with `harn init` or manually.");
process::exit(1);
}
};
match toml::from_str::<Manifest>(&content) {
Ok(m) => m,
Err(e) => {
eprintln!("Failed to parse harn.toml: {e}");
process::exit(1);
}
}
}
pub fn try_read_manifest_for(harn_file: &std::path::Path) -> Option<Manifest> {
let dir = harn_file.parent().unwrap_or(std::path::Path::new("."));
let manifest_path = dir.join(MANIFEST);
let content = fs::read_to_string(&manifest_path).ok()?;
match toml::from_str::<Manifest>(&content) {
Ok(m) => Some(m),
Err(e) => {
eprintln!("warning: failed to parse {}: {e}", manifest_path.display());
None
}
}
}
fn absolutize_check_config_paths(mut config: CheckConfig, manifest_dir: &Path) -> CheckConfig {
if let Some(path) = config.host_capabilities_path.clone() {
let candidate = PathBuf::from(&path);
if !candidate.is_absolute() {
config.host_capabilities_path =
Some(manifest_dir.join(candidate).display().to_string());
}
}
if let Some(path) = config.bundle_root.clone() {
let candidate = PathBuf::from(&path);
if !candidate.is_absolute() {
config.bundle_root = Some(manifest_dir.join(candidate).display().to_string());
}
}
config
}
pub fn load_check_config(harn_file: Option<&std::path::Path>) -> CheckConfig {
if let Some(path) = harn_file {
let manifest_dir = path.parent().unwrap_or(Path::new("."));
if let Some(manifest) = try_read_manifest_for(path) {
return absolutize_check_config_paths(manifest.check, manifest_dir);
}
}
let mut dir = std::env::current_dir().unwrap_or_default();
loop {
let manifest_path = dir.join(MANIFEST);
if manifest_path.exists() {
if let Ok(content) = fs::read_to_string(&manifest_path) {
if let Ok(manifest) = toml::from_str::<Manifest>(&content) {
return absolutize_check_config_paths(manifest.check, &dir);
}
}
}
if !dir.pop() {
break;
}
}
CheckConfig::default()
}
pub fn install_packages() {
let manifest = read_manifest();
if manifest.dependencies.is_empty() {
println!("No dependencies to install.");
return;
}
let has_git_deps = manifest
.dependencies
.values()
.any(|d| d.git_url().is_some());
if has_git_deps
&& process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
eprintln!("Error: git is required to install git dependencies but was not found.");
eprintln!("Install git and ensure it's in your PATH.");
process::exit(1);
}
let pkg_dir = PathBuf::from(PKG_DIR);
if let Err(e) = fs::create_dir_all(&pkg_dir) {
eprintln!("Failed to create {PKG_DIR}: {e}");
process::exit(1);
}
let mut lock = LockFile::load(Path::new(LOCK_FILE));
let mut installed = 0;
let mut visiting = HashSet::new();
for (name, dep) in &manifest.dependencies {
install_one(
name,
dep,
&pkg_dir,
&mut lock,
&mut visiting,
&mut installed,
);
}
lock.save(Path::new(LOCK_FILE));
println!("\nInstalled {installed} package(s) to {PKG_DIR}/");
}
fn install_one(
name: &str,
dep: &Dependency,
pkg_dir: &Path,
lock: &mut LockFile,
visiting: &mut HashSet<String>,
installed: &mut usize,
) {
if !visiting.insert(name.to_string()) {
eprintln!(" warning: circular dependency detected for '{name}', skipping");
return;
}
let dest = pkg_dir.join(name);
if let Some(git_url) = dep.git_url() {
install_git_dep(name, git_url, dep.tag(), &dest, lock);
*installed += 1;
} else if let Some(local_path) = dep.local_path() {
install_local_dep(name, local_path, &dest);
*installed += 1;
lock.entries.insert(
name.to_string(),
LockEntry {
git: None,
tag: None,
commit: None,
path: Some(local_path.to_string()),
},
);
} else {
eprintln!(" {name}: no git or path specified, skipping");
visiting.remove(name);
return;
}
let sub_manifest_path = dest.join("harn.toml");
if sub_manifest_path.exists() {
if let Ok(content) = fs::read_to_string(&sub_manifest_path) {
if let Ok(sub_manifest) = toml::from_str::<Manifest>(&content) {
for (sub_name, sub_dep) in &sub_manifest.dependencies {
let sub_dest = pkg_dir.join(sub_name);
if !sub_dest.exists() {
install_one(sub_name, sub_dep, pkg_dir, lock, visiting, installed);
}
}
}
}
}
visiting.remove(name);
}
fn install_git_dep(name: &str, git_url: &str, tag: Option<&str>, dest: &Path, lock: &mut LockFile) {
if let Some(entry) = lock.entries.get(name) {
if entry.git.as_deref() == Some(git_url)
&& entry.tag.as_deref() == tag
&& entry.commit.is_some()
&& dest.exists()
{
println!(" {name}: up to date (locked)");
return;
}
}
if dest.exists() {
println!(" updating {name} from {git_url}");
let _ = fs::remove_dir_all(dest);
} else {
println!(" installing {name} from {git_url}");
}
let mut cmd = process::Command::new("git");
cmd.args(["clone", "--depth", "1"]);
if let Some(t) = tag {
cmd.args(["--branch", t]);
}
cmd.arg(git_url);
cmd.arg(dest.as_os_str());
cmd.stdout(process::Stdio::null());
cmd.stderr(process::Stdio::piped());
match cmd.output() {
Ok(output) if output.status.success() => {
let commit = get_git_commit(dest);
let _ = fs::remove_dir_all(dest.join(".git"));
lock.entries.insert(
name.to_string(),
LockEntry {
git: Some(git_url.to_string()),
tag: tag.map(|t| t.to_string()),
commit,
path: None,
},
);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!(" failed to clone {name}: {stderr}");
}
Err(e) => {
eprintln!(" failed to run git for {name}: {e}");
eprintln!(" hint: make sure git is installed and in PATH");
}
}
}
fn get_git_commit(repo_dir: &Path) -> Option<String> {
let output = process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_dir)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn install_local_dep(name: &str, source_path: &str, dest: &Path) {
let source = Path::new(source_path);
if source.is_dir() {
if dest.exists() {
println!(" updating {name} from {source_path}");
let _ = fs::remove_dir_all(dest);
} else {
println!(" installing {name} from {source_path}");
}
if let Err(e) = copy_dir_recursive(source, dest) {
eprintln!(" failed to install {name}: {e}");
}
} else if source.is_file() {
let dest_file = dest.with_extension("harn");
if dest_file.exists() {
println!(" updating {name} from {source_path}");
} else {
println!(" installing {name} from {source_path}");
}
if let Some(parent) = dest_file.parent() {
fs::create_dir_all(parent).ok();
}
if let Err(e) = fs::copy(source, &dest_file) {
eprintln!(" failed to install {name}: {e}");
}
} else {
let harn_source = PathBuf::from(format!("{source_path}.harn"));
if harn_source.exists() {
let dest_file = dest.with_extension("harn");
println!(" installing {name} from {}", harn_source.display());
if let Some(parent) = dest_file.parent() {
fs::create_dir_all(parent).ok();
}
if let Err(e) = fs::copy(&harn_source, &dest_file) {
eprintln!(" failed to install {name}: {e}");
}
} else {
eprintln!(" package source not found: {source_path}");
}
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
pub fn add_package(name: &str, git_url: Option<&str>, tag: Option<&str>, local_path: Option<&str>) {
if git_url.is_none() && local_path.is_none() {
eprintln!("Must specify --git <url> or --path <local-path>");
process::exit(1);
}
let manifest_path = Path::new(MANIFEST);
let mut content = if manifest_path.exists() {
fs::read_to_string(manifest_path).unwrap_or_default()
} else {
"[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string()
};
if !content.contains("[dependencies]") {
content.push_str("\n[dependencies]\n");
}
let dep_line = if let Some(url) = git_url {
if let Some(t) = tag {
format!("{name} = {{ git = \"{url}\", tag = \"{t}\" }}")
} else {
format!("{name} = {{ git = \"{url}\" }}")
}
} else {
let p = local_path.unwrap();
format!("{name} = {{ path = \"{p}\" }}")
};
let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let mut replaced = false;
for line in &mut lines {
if line.starts_with(name) && line.contains('=') {
let before_eq = line.split('=').next().unwrap_or("").trim();
if before_eq == name {
*line = dep_line.clone();
replaced = true;
break;
}
}
}
if !replaced {
let dep_idx = lines
.iter()
.position(|l| l.trim() == "[dependencies]")
.unwrap_or_else(|| {
lines.push("[dependencies]".to_string());
lines.len() - 1
});
lines.insert(dep_idx + 1, dep_line);
}
let new_content = lines.join("\n") + "\n";
if let Err(e) = fs::write(manifest_path, &new_content) {
eprintln!("Failed to write harn.toml: {e}");
process::exit(1);
}
println!("Added {name} to harn.toml");
println!("Run `harn install` to fetch the package.");
}