use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::intent::UseCase;
pub const ACTION_LEDGER_FILE: &str = "concierge-actions.jsonl";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConciergeActionKind {
Install,
SetDefault,
ClearDefault,
Rollback,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConciergeActionEntry {
#[serde(default)]
pub seq: u64,
pub kind: ConciergeActionKind,
pub model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_case: Option<UseCase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior_model_id: Option<String>,
#[serde(default)]
pub detail: String,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConciergeApplyResult {
pub model_id: String,
pub use_case: UseCase,
pub installed: bool,
pub set_default: bool,
pub prior_model_id: Option<String>,
}
pub fn default_path() -> PathBuf {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".car").join(ACTION_LEDGER_FILE)
}
pub fn append_action(path: &Path, entry: &ConciergeActionEntry) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
let line = serde_json::to_string(entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")
}
pub fn read_actions(path: &Path, limit: usize) -> Vec<ConciergeActionEntry> {
let Ok(content) = std::fs::read_to_string(path) else {
return Vec::new();
};
let mut out: Vec<ConciergeActionEntry> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
if limit > 0 && out.len() > limit {
out = out.split_off(out.len() - limit);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn append_and_read_roundtrip() {
let dir = std::env::temp_dir().join("car-action-ledger-test");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join(ACTION_LEDGER_FILE);
let e = ConciergeActionEntry {
seq: 1,
kind: ConciergeActionKind::SetDefault,
model_id: "big-coder".into(),
use_case: Some(UseCase::Coding),
project: None,
prior_model_id: Some("small-coder".into()),
detail: "concierge apply".into(),
timestamp: 100,
};
append_action(&path, &e).unwrap();
let read = read_actions(&path, 0);
assert_eq!(read.len(), 1);
assert_eq!(read[0].kind, ConciergeActionKind::SetDefault);
assert_eq!(read[0].prior_model_id.as_deref(), Some("small-coder"));
let _ = std::fs::remove_dir_all(&dir);
}
}