use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use openmoat_hosts::{AlsoDir, Host};
use serde::{Deserialize, Serialize};
use crate::home::{Home, write_private};
const VERSION: u32 = 1;
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recorded {
pub version: u32,
pub dirs: BTreeMap<String, PathBuf>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub files: BTreeMap<String, PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub claude_rules: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub backups: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_wrapped: Vec<crate::mcp::wrap::Wrapped>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub also: BTreeMap<String, Vec<PathBuf>>,
}
impl Recorded {
pub fn load(home: &Home) -> Result<Self> {
home.load_state(&home.hosts_path(), Self::parse, |path| {
if !path.exists() {
return Ok(Self::default());
}
let text =
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Self::parse(&text, path)
})
}
pub fn parse(text: &str, path: &Path) -> Result<Self> {
let recorded: Self = serde_json::from_str(text)
.with_context(|| format!("parsing {}; run `openmoat init`", path.display()))?;
if recorded.version != VERSION {
bail!(
"{} has version {}; this build supports {VERSION}",
path.display(),
recorded.version
);
}
Ok(recorded)
}
pub fn save(&mut self, home: &Home) -> Result<()> {
self.version = VERSION;
let text = serde_json::to_string_pretty(self)? + "\n";
write_private(&home.hosts_path(), text.as_bytes())
}
pub fn owns(&self, path: &Path) -> bool {
self.backups.iter().any(|b| b == path)
}
pub fn dir(&self, host: Host) -> Option<PathBuf> {
self.dirs.get(host.id()).cloned()
}
pub fn file(&self, host: Host) -> Option<PathBuf> {
self.files.get(host.id()).cloned()
}
pub fn also(&self, host: Host) -> &[PathBuf] {
self.also.get(host.id()).map_or(&[], Vec::as_slice)
}
pub fn insert(&mut self, config: &super::HostConfig) {
let id = config.host.id().to_owned();
let also = also_dirs(config.host, config.dir());
if also.is_empty() {
self.also.remove(&id);
} else {
self.also.insert(id.clone(), also);
}
self.dirs.insert(id.clone(), config.dir().to_path_buf());
match config.custom_location() {
Some((locate, located)) if !locate.config_is_default(&located) => {
self.files.insert(id, located.config);
}
_ => {
self.files.remove(&id);
}
}
}
pub fn skipped(&self, host: Host) -> bool {
!self.dirs.is_empty() && self.dir(host).is_none()
}
}
pub fn also_dirs(host: Host, dir: &Path) -> Vec<PathBuf> {
let Some(install) = host.spec().install.as_ref() else {
return Vec::new();
};
let Ok(home) = crate::home::user_home() else {
return Vec::new();
};
let mut found: Vec<PathBuf> = Vec::new();
for also in install.also_dirs {
match also {
AlsoDir::Variable(name) => found.extend(
std::env::var_os(name)
.filter(|v| !v.is_empty())
.and_then(|v| crate::context::absolute(Path::new(&v)).ok()),
),
AlsoDir::Below(name, child) => found.extend(
std::env::var_os(name)
.filter(|v| !v.is_empty())
.and_then(|v| crate::context::absolute(&Path::new(&v).join(child)).ok()),
),
AlsoDir::EachIn(folder) => {
if let Ok(entries) = fs::read_dir(dir.join(folder)) {
found.extend(entries.filter_map(|e| Some(e.ok()?.path())));
}
}
}
}
found.retain(|d| d.is_dir() && d != dir && crate::project::trusted(d, &home));
found.sort();
found.dedup();
found
}
#[cfg(test)]
mod tests;