use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(super) struct DiscussionRecord {
pub id: String,
pub repo: PathBuf,
pub principal: String,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_task: Option<(String, Vec<String>)>,
}
fn record_path(root: &Path, id: &str) -> Result<PathBuf, String> {
let suffix = id.strip_prefix("disc-").ok_or("invalid conversation id")?;
if suffix.len() != 32 || !suffix.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("invalid conversation id".into());
}
Ok(root.join("coder-discussions").join(format!("{id}.json")))
}
impl DiscussionRecord {
pub fn list(root: &Path, principal: &str) -> Result<Vec<Self>, String> {
let entries = match std::fs::read_dir(root.join("coder-discussions")) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(format!("read saved conversations: {e}")),
};
let mut records = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("read saved conversation: {e}"))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(record) = Self::load(root, id, principal) {
records.push(record);
}
}
records.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id)));
Ok(records)
}
pub fn load(root: &Path, id: &str, principal: &str) -> Result<Self, String> {
let path = record_path(root, id)?;
let bytes =
std::fs::read(path).map_err(|_| "saved conversation is unavailable".to_string())?;
let record: Self = serde_json::from_slice(&bytes)
.map_err(|_| "saved conversation record is invalid".to_string())?;
if record.id != id || record.principal != principal {
return Err("saved conversation is unavailable".into());
}
Ok(record)
}
pub fn save(&self, root: &Path) -> Result<(), String> {
self.write(root, false)
}
pub fn save_model(&self, root: &Path) -> Result<(), String> {
let prior = Self::load(root, &self.id, &self.principal)?;
if prior.repo != self.repo || prior.created_at != self.created_at {
return Err("conversation identity changed while selecting a model".into());
}
self.write(root, true)
}
fn write(&self, root: &Path, replace: bool) -> Result<(), String> {
use std::io::Write;
let path = record_path(root, &self.id)?;
let parent = path.parent().ok_or("invalid conversation path")?;
car_secrets::ensure_private_dir(parent)
.map_err(|e| format!("create private conversation store: {e}"))?;
let mut file = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| format!("create conversation record: {e}"))?;
let bytes = serde_json::to_vec(self).map_err(|e| e.to_string())?;
file.write_all(&bytes)
.and_then(|_| file.as_file().sync_all())
.map_err(|e| format!("save conversation: {e}"))?;
if replace {
file.persist(&path)
} else {
file.persist_noclobber(&path)
}
.map_err(|e| format!("publish conversation record: {e}"))?;
#[cfg(unix)]
std::fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|e| format!("sync conversation directory: {e}"))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ownership_binding_cannot_be_replaced_or_loaded_by_another_principal() {
let dir = tempfile::tempdir().unwrap();
let record = DiscussionRecord {
id: format!("disc-{}", uuid::Uuid::new_v4().simple()),
repo: dir.path().join("repo"),
principal: "operator".into(),
created_at: 1,
model: None,
pending_task: None,
};
record.save(dir.path()).unwrap();
assert!(DiscussionRecord::load(dir.path(), &record.id, "agent:other").is_err());
assert!(record.save(dir.path()).is_err());
assert_eq!(
DiscussionRecord::load(dir.path(), &record.id, "operator")
.unwrap()
.repo,
record.repo
);
assert!(DiscussionRecord::load(dir.path(), "../../secret", "operator").is_err());
}
}