Skip to main content

atman_runtime/
flow_meta.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6const HASH_PREFIX_LEN: usize = 12;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct FlowMeta {
10    pub version: String,
11    #[serde(default)]
12    pub description: Option<String>,
13    #[serde(default)]
14    pub last_modified: Option<chrono::DateTime<chrono::Utc>>,
15    #[serde(default)]
16    pub author: Option<String>,
17    #[serde(default)]
18    pub tags: Vec<String>,
19    #[serde(default, skip)]
20    pub source: FlowMetaSource,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum FlowMetaSource {
25    Sidecar,
26    #[default]
27    HashFallback,
28}
29
30impl FlowMeta {
31    pub fn load(at_path: &Path) -> Result<Self> {
32        let src = std::fs::read_to_string(at_path)
33            .with_context(|| format!("read flow source {}", at_path.display()))?;
34        Self::from_source(at_path, &src)
35    }
36
37    pub fn from_source(at_path: &Path, at_source: &str) -> Result<Self> {
38        let sidecar = sidecar_path(at_path);
39        if sidecar.exists() {
40            return load_sidecar(&sidecar).map(|mut m| {
41                m.source = FlowMetaSource::Sidecar;
42                m
43            });
44        }
45        Ok(hash_meta(at_source))
46    }
47
48    pub fn is_sidecar(&self) -> bool {
49        matches!(self.source, FlowMetaSource::Sidecar)
50    }
51
52    pub fn short_hash(at_source: &str) -> String {
53        let hash = blake3::hash(at_source.as_bytes()).to_hex().to_string();
54        hash.chars().take(HASH_PREFIX_LEN).collect()
55    }
56}
57
58pub fn sidecar_path(at_path: &Path) -> PathBuf {
59    let name = at_path
60        .file_name()
61        .map(|s| s.to_string_lossy().to_string())
62        .unwrap_or_default();
63    let with_meta = format!("{name}.meta.toml");
64    at_path.with_file_name(with_meta)
65}
66
67fn load_sidecar(path: &Path) -> Result<FlowMeta> {
68    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
69    let mut meta: FlowMeta =
70        toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
71    if meta.version.trim().is_empty() {
72        anyhow::bail!("{}: `version` must be non-empty", path.display());
73    }
74    meta.source = FlowMetaSource::Sidecar;
75    Ok(meta)
76}
77
78fn hash_meta(at_source: &str) -> FlowMeta {
79    FlowMeta {
80        version: format!("hash:{}", FlowMeta::short_hash(at_source)),
81        description: None,
82        last_modified: None,
83        author: None,
84        tags: Vec::new(),
85        source: FlowMetaSource::HashFallback,
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn hash_fallback_when_no_sidecar() {
95        let dir = tempfile::tempdir().unwrap();
96        let at = dir.path().join("greet.at");
97        std::fs::write(&at, "flow greet() { return 1 }").unwrap();
98        let meta = FlowMeta::load(&at).unwrap();
99        assert!(!meta.is_sidecar());
100        assert!(meta.version.starts_with("hash:"));
101        assert_eq!(meta.version.len(), "hash:".len() + HASH_PREFIX_LEN);
102    }
103
104    #[test]
105    fn hash_is_stable_and_content_addressed() {
106        let a = FlowMeta::short_hash("flow x() { return 1 }");
107        let b = FlowMeta::short_hash("flow x() { return 1 }");
108        let c = FlowMeta::short_hash("flow x() { return 2 }");
109        assert_eq!(a, b);
110        assert_ne!(a, c);
111    }
112
113    #[test]
114    fn sidecar_wins_over_hash() {
115        let dir = tempfile::tempdir().unwrap();
116        let at = dir.path().join("greet.at");
117        std::fs::write(&at, "flow greet() { return 1 }").unwrap();
118        std::fs::write(
119            dir.path().join("greet.at.meta.toml"),
120            r#"version = "0.3.1"
121description = "greet the user"
122author = "w-mai"
123tags = ["hello", "demo"]
124"#,
125        )
126        .unwrap();
127        let meta = FlowMeta::load(&at).unwrap();
128        assert!(meta.is_sidecar());
129        assert_eq!(meta.version, "0.3.1");
130        assert_eq!(meta.description.as_deref(), Some("greet the user"));
131        assert_eq!(meta.author.as_deref(), Some("w-mai"));
132        assert_eq!(meta.tags, vec!["hello", "demo"]);
133    }
134
135    #[test]
136    fn sidecar_missing_version_rejected() {
137        let dir = tempfile::tempdir().unwrap();
138        let at = dir.path().join("foo.at");
139        std::fs::write(&at, "flow foo() { return 1 }").unwrap();
140        std::fs::write(
141            dir.path().join("foo.at.meta.toml"),
142            r#"version = ""
143description = "bad"
144"#,
145        )
146        .unwrap();
147        let err = FlowMeta::load(&at).unwrap_err();
148        assert!(err.to_string().contains("version"));
149    }
150
151    #[test]
152    fn sidecar_path_beside_at_file() {
153        let p = sidecar_path(Path::new("/tmp/agents/review.at"));
154        assert_eq!(p, PathBuf::from("/tmp/agents/review.at.meta.toml"));
155    }
156
157    #[test]
158    fn from_source_reads_sidecar_without_reading_at_file() {
159        let dir = tempfile::tempdir().unwrap();
160        let at = dir.path().join("v.at");
161        std::fs::write(dir.path().join("v.at.meta.toml"), "version = \"1.2.3\"\n").unwrap();
162        let meta = FlowMeta::from_source(&at, "flow v() { return 1 }").unwrap();
163        assert_eq!(meta.version, "1.2.3");
164        assert!(meta.is_sidecar());
165    }
166}