use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct IntentFile {
#[serde(default, rename = "schema_url")]
pub schema_url: Option<String>,
#[serde(default)]
pub models: BTreeMap<String, ModelIntent>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ModelIntent {
#[serde(default)]
pub rationale: String,
#[serde(default)]
pub fields: BTreeMap<String, FieldIntent>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FieldIntent {
#[serde(default)]
pub rationale: String,
#[serde(default)]
pub added_by: String,
#[serde(default)]
pub added_at: String,
}
#[derive(Debug, thiserror::Error)]
pub enum IntentError {
#[error("failed to read {path}: {source}")]
Io {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("malformed intent JSON in {path}: {source}")]
Parse {
path: std::path::PathBuf,
#[source]
source: serde_json::Error,
},
}
pub fn load(workspace_root: &Path) -> Result<Option<IntentFile>, IntentError> {
let path = workspace_root.join(".djogi").join("intent.json");
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(IntentError::Io { path, source }),
};
let parsed: IntentFile =
serde_json::from_slice(&bytes).map_err(|source| IntentError::Parse {
path: path.clone(),
source,
})?;
Ok(Some(parsed))
}
pub fn resolve_model_rationale<'a>(
macro_attr: Option<&'a str>,
intent: Option<&'a ModelIntent>,
) -> Option<&'a str> {
if let Some(s) = macro_attr {
return Some(s);
}
intent
.map(|i| i.rationale.as_str())
.filter(|s| !s.is_empty())
}
pub fn resolve_field_rationale<'a>(
macro_attr: Option<&'a str>,
intent: Option<&'a FieldIntent>,
) -> Option<&'a str> {
if let Some(s) = macro_attr {
return Some(s);
}
intent
.map(|i| i.rationale.as_str())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let pid = std::process::id();
let path = std::env::temp_dir().join(format!("djogi-intent-{label}-{pid}-{nanos}"));
std::fs::create_dir_all(&path).expect("create tempdir");
TempDir(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_intent(dir: &Path, contents: &str) {
let djogi_dir = dir.join(".djogi");
std::fs::create_dir_all(&djogi_dir).expect("create .djogi");
let mut f =
std::fs::File::create(djogi_dir.join("intent.json")).expect("create intent.json");
f.write_all(contents.as_bytes()).expect("write intent.json");
}
#[test]
fn load_returns_none_when_file_absent() {
let dir = TempDir::new("test");
let result = load(dir.path()).expect("load");
assert!(result.is_none(), "absent intent file must yield Ok(None)");
}
#[test]
fn load_parses_valid_intent_json() {
let dir = TempDir::new("test");
write_intent(
dir.path(),
r#"{
"schema_url": "https://djogi.dev/schemas/v1/intent.json",
"models": {
"Vehicle": {
"rationale": "Tracks fleet inventory.",
"fields": {
"vin": {
"rationale": "Unique manufacturer id.",
"added_by": "fleet-team",
"added_at": "2026-04-12T09:30:00Z"
}
}
}
}
}"#,
);
let file = load(dir.path()).expect("load").expect("Some(file)");
assert_eq!(
file.schema_url.as_deref(),
Some("https://djogi.dev/schemas/v1/intent.json")
);
let model = file.models.get("Vehicle").expect("Vehicle entry");
assert_eq!(model.rationale, "Tracks fleet inventory.");
let field = model.fields.get("vin").expect("vin entry");
assert_eq!(field.rationale, "Unique manufacturer id.");
assert_eq!(field.added_by, "fleet-team");
assert_eq!(field.added_at, "2026-04-12T09:30:00Z");
}
#[test]
fn load_returns_err_on_malformed_json() {
let dir = TempDir::new("test");
write_intent(dir.path(), "{ this is not json }");
let result = load(dir.path());
assert!(matches!(result, Err(IntentError::Parse { .. })));
}
#[test]
fn load_accepts_minimal_document() {
let dir = TempDir::new("test");
write_intent(dir.path(), "{}");
let file = load(dir.path()).expect("load").expect("Some(file)");
assert!(file.schema_url.is_none());
assert!(file.models.is_empty());
}
#[test]
fn resolve_model_rationale_macro_attr_wins() {
let intent = ModelIntent {
rationale: "intent.json text".to_string(),
fields: BTreeMap::new(),
};
let resolved = resolve_model_rationale(Some("macro-attr text"), Some(&intent));
assert_eq!(resolved, Some("macro-attr text"));
}
#[test]
fn resolve_model_rationale_intent_fallback() {
let intent = ModelIntent {
rationale: "intent.json text".to_string(),
fields: BTreeMap::new(),
};
let resolved = resolve_model_rationale(None, Some(&intent));
assert_eq!(resolved, Some("intent.json text"));
}
#[test]
fn resolve_model_rationale_empty_intent_treated_as_absent() {
let intent = ModelIntent {
rationale: String::new(),
fields: BTreeMap::new(),
};
let resolved = resolve_model_rationale(None, Some(&intent));
assert_eq!(resolved, None);
}
#[test]
fn resolve_model_rationale_neither_present_returns_none() {
let resolved = resolve_model_rationale(None, None);
assert_eq!(resolved, None);
}
#[test]
fn resolve_field_rationale_macro_attr_wins() {
let field = FieldIntent {
rationale: "intent text".to_string(),
added_by: "x".to_string(),
added_at: "2026-01-01T00:00:00Z".to_string(),
};
let resolved = resolve_field_rationale(Some("macro text"), Some(&field));
assert_eq!(resolved, Some("macro text"));
}
#[test]
fn resolve_field_rationale_intent_fallback() {
let field = FieldIntent {
rationale: "intent text".to_string(),
added_by: "x".to_string(),
added_at: "2026-01-01T00:00:00Z".to_string(),
};
let resolved = resolve_field_rationale(None, Some(&field));
assert_eq!(resolved, Some("intent text"));
}
#[test]
fn resolve_field_rationale_neither_present_returns_none() {
let resolved = resolve_field_rationale(None, None);
assert_eq!(resolved, None);
}
#[test]
fn iteration_is_alphabetical_by_key() {
let mut models: BTreeMap<String, ModelIntent> = BTreeMap::new();
models.insert("Zebra".to_string(), ModelIntent::default());
models.insert("Alpha".to_string(), ModelIntent::default());
models.insert("Mike".to_string(), ModelIntent::default());
let keys: Vec<&str> = models.keys().map(|s| s.as_str()).collect();
assert_eq!(keys, vec!["Alpha", "Mike", "Zebra"]);
}
}