use std::collections::BTreeMap;
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::paths::{APPLIED_FILE, PJ_STATE_DIR, applied_path};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AppliedState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_dir: Option<Utf8PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub templates: Vec<AppliedTemplate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applied_at: Option<jiff::Timestamp>,
#[serde(default, skip_serializing_if = "toml::Table::is_empty")]
pub vars: toml::Table,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub files: BTreeMap<String, FileState>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppliedTemplate {
pub source: String,
pub rev: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct FileState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_ai_run: Option<jiff::Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_decision: Option<Decision>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub once_applied: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Decision {
Accept,
Edit,
Skip,
Defer,
}
fn is_false(b: &bool) -> bool {
!*b
}
fn templates_need_base_dir(templates: &[AppliedTemplate]) -> bool {
templates.iter().any(|t| is_relative_source(&t.source))
}
fn is_relative_source(s: &str) -> bool {
s.starts_with("./") || s.starts_with("../") || s.starts_with(".\\") || s.starts_with("..\\")
}
impl AppliedState {
pub fn load(pj_root: &Utf8Path) -> Result<Self> {
let path = applied_path(pj_root);
if !path.exists() {
return Ok(Self::default());
}
let raw =
std::fs::read_to_string(&path).map_err(|e| Error::io_at(path.as_std_path(), e))?;
toml::from_str(&raw).map_err(|e| Error::applied(path.as_std_path(), e.message()))
}
pub fn save(&self, pj_root: &Utf8Path) -> Result<()> {
let dir = pj_root.join(PJ_STATE_DIR);
std::fs::create_dir_all(&dir).map_err(|e| Error::io_at(dir.as_std_path(), e))?;
let path = dir.join(APPLIED_FILE);
let mut view = self.clone();
if !templates_need_base_dir(&view.templates) {
view.base_dir = None;
}
let body = toml::to_string_pretty(&view)
.map_err(|e| Error::applied(path.as_std_path(), e.to_string()))?;
std::fs::write(&path, body).map_err(|e| Error::io_at(path.as_std_path(), e))
}
pub fn record(&mut self, dst: impl Into<String>, state: FileState) {
self.files.insert(dst.into(), state);
}
pub fn promote_template(&mut self, t: AppliedTemplate) {
if let Some(slot) = self.templates.iter_mut().find(|x| x.source == t.source) {
*slot = t;
} else {
self.templates.push(t);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use camino::Utf8PathBuf;
use tempfile::TempDir;
#[test]
fn round_trip_minimal() {
let td = TempDir::new().unwrap();
let pj = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
let mut s = AppliedState::default();
s.promote_template(AppliedTemplate {
source: "/local/pj-base".into(),
rev: "local".into(),
subdir: None,
version: None,
});
s.record(
"Makefile.toml",
FileState {
content_hash: Some("abc".into()),
..Default::default()
},
);
s.save(&pj).unwrap();
let loaded = AppliedState::load(&pj).unwrap();
assert_eq!(loaded.templates.len(), 1);
assert_eq!(loaded.templates[0].source, "/local/pj-base");
assert_eq!(
loaded.files["Makefile.toml"].content_hash.as_deref(),
Some("abc")
);
}
#[test]
fn load_returns_default_when_missing() {
let td = TempDir::new().unwrap();
let pj = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
let s = AppliedState::load(&pj).unwrap();
assert!(s.templates.is_empty());
assert!(s.preset.is_none());
assert!(s.base_dir.is_none());
}
#[test]
fn base_dir_is_kept_when_a_template_uses_a_relative_source() {
let td = TempDir::new().unwrap();
let pj = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
let recorded_base = Utf8PathBuf::from("/abs/preset-dir");
let mut s = AppliedState {
base_dir: Some(recorded_base.clone()),
..Default::default()
};
s.promote_template(AppliedTemplate {
source: "./pj-base".into(),
rev: "local".into(),
subdir: None,
version: None,
});
s.save(&pj).unwrap();
let loaded = AppliedState::load(&pj).unwrap();
assert_eq!(loaded.base_dir.as_ref(), Some(&recorded_base));
}
#[test]
fn base_dir_is_dropped_when_all_sources_are_remote() {
let td = TempDir::new().unwrap();
let pj = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
let mut s = AppliedState {
base_dir: Some(Utf8PathBuf::from("/abs/cache/slot")),
..Default::default()
};
s.promote_template(AppliedTemplate {
source: "github.com/yukimemi/pj-base".into(),
rev: "deadbeef".into(),
subdir: None,
version: None,
});
s.save(&pj).unwrap();
let loaded = AppliedState::load(&pj).unwrap();
assert!(
loaded.base_dir.is_none(),
"expected base_dir to be omitted from committed applied.toml \
when no template needs a relative-source resolution base, got {:?}",
loaded.base_dir
);
}
#[test]
fn promote_template_replaces_existing() {
let mut s = AppliedState::default();
s.promote_template(AppliedTemplate {
source: "x".into(),
rev: "1".into(),
subdir: None,
version: None,
});
s.promote_template(AppliedTemplate {
source: "x".into(),
rev: "2".into(),
subdir: None,
version: None,
});
s.promote_template(AppliedTemplate {
source: "y".into(),
rev: "1".into(),
subdir: None,
version: None,
});
assert_eq!(s.templates.len(), 2);
assert_eq!(s.templates[0].rev, "2");
}
}