atman_runtime/
flow_meta.rs1use 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}