use crate::config::MemgineConfig;
use crate::graph::{FactMetadata, Provenance};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const DOT_CAR_DIR: &str = ".car";
#[derive(Debug, Clone)]
pub struct DotCarProject {
pub path: PathBuf,
pub identity: Option<String>,
pub knowledge: Vec<KnowledgeEntry>,
pub rubrics: Vec<car_ir::rubric::Rubric>,
pub policies: Vec<PolicyRule>,
pub config_overrides: Option<ConfigOverrides>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeEntry {
pub id: String,
#[serde(rename = "type", default)]
pub entry_type: String,
pub fact: String,
#[serde(default)]
pub recommendation: String,
#[serde(default)]
pub confidence: String,
#[serde(default)]
pub provenance: Vec<ProvenanceEntry>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, rename = "affectedFiles")]
pub affected_files: Vec<String>,
#[serde(default)]
pub category: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvenanceEntry {
pub source: String,
#[serde(default)]
pub reference: String,
#[serde(default)]
pub date: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRule {
pub rule: String,
#[serde(default)]
pub tool: String,
#[serde(default)]
pub param: String,
#[serde(default)]
pub denied_values: Vec<String>,
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConfigOverrides {
#[serde(default)]
pub token_budget: Option<usize>,
#[serde(default)]
pub conversation_keep_recent: Option<usize>,
#[serde(default)]
pub compaction_batch_size: Option<usize>,
#[serde(default)]
pub environment_max: Option<usize>,
#[serde(default)]
pub max_skills_in_context: Option<usize>,
#[serde(default)]
pub prefer_local: Option<bool>,
#[serde(default)]
pub routing_prefer_local: Option<bool>,
#[serde(default)]
pub routing_quality_first_cold_start: Option<bool>,
#[serde(default)]
pub routing_bootstrap_min_task_observations: Option<u64>,
#[serde(default)]
pub routing_bootstrap_quality_floor: Option<f64>,
#[serde(default)]
pub preferred_generation_model: Option<String>,
#[serde(default)]
pub preferred_embedding_model: Option<String>,
#[serde(default)]
pub preferred_classification_model: Option<String>,
#[serde(default)]
pub speech_prefer_local: Option<bool>,
#[serde(default)]
pub speech_allow_remote_fallback: Option<bool>,
#[serde(default)]
pub speech_preferred_local_stt: Option<String>,
#[serde(default)]
pub speech_preferred_local_tts: Option<String>,
#[serde(default)]
pub speech_preferred_remote_stt: Option<String>,
#[serde(default)]
pub speech_preferred_remote_tts: Option<String>,
}
impl ConfigOverrides {
pub fn apply(&self, mut config: MemgineConfig) -> MemgineConfig {
if let Some(v) = self.token_budget {
config.token_budget = v;
}
if let Some(v) = self.conversation_keep_recent {
config.conversation_keep_recent = v;
}
if let Some(v) = self.compaction_batch_size {
config.compaction_batch_size = v;
}
if let Some(v) = self.environment_max {
config.environment_max = v;
}
if let Some(v) = self.max_skills_in_context {
config.max_skills_in_context = v;
}
config
}
}
pub fn discover_project(start_dir: &Path) -> Option<PathBuf> {
let mut current = start_dir.to_path_buf();
loop {
let candidate = current.join(DOT_CAR_DIR);
if candidate.is_dir() {
return Some(candidate);
}
if !current.pop() {
return None;
}
}
}
pub fn load_project(car_dir: &Path) -> Result<DotCarProject, String> {
if !car_dir.is_dir() {
return Err(format!("{} is not a directory", car_dir.display()));
}
let identity_path = car_dir.join("identity.md");
let identity = if identity_path.exists() {
Some(
std::fs::read_to_string(&identity_path)
.map_err(|e| format!("read identity.md: {}", e))?,
)
} else {
None
};
let knowledge_dir = car_dir.join("knowledge");
let mut knowledge = Vec::new();
if knowledge_dir.is_dir() {
for entry in
std::fs::read_dir(&knowledge_dir).map_err(|e| format!("read knowledge/: {}", e))?
{
let entry = entry.map_err(|e| format!("read entry: {}", e))?;
let path = entry.path();
if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("read {}: {}", path.display(), e))?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue; }
if let Ok(entry) = serde_json::from_str::<KnowledgeEntry>(trimmed) {
knowledge.push(entry);
}
}
}
}
}
let rubrics_dir = car_dir.join("rubrics");
let mut rubrics = Vec::new();
if rubrics_dir.is_dir() {
for entry in std::fs::read_dir(&rubrics_dir).map_err(|e| format!("read rubrics/: {}", e))? {
let entry = entry.map_err(|e| format!("read entry: {}", e))?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("read {}: {}", path.display(), e))?;
if let Ok(rubric) = serde_json::from_str::<car_ir::rubric::Rubric>(&content) {
rubrics.push(rubric);
}
}
}
}
let policies_path = car_dir.join("policies.json");
let policies = if policies_path.exists() {
let content = std::fs::read_to_string(&policies_path)
.map_err(|e| format!("read policies.json: {}", e))?;
serde_json::from_str::<Vec<PolicyRule>>(&content)
.map_err(|e| format!("parse policies.json: {}", e))?
} else {
Vec::new()
};
let config_path = car_dir.join("config.toml");
let config_overrides = if config_path.exists() {
let content = std::fs::read_to_string(&config_path)
.map_err(|e| format!("read config.toml: {}", e))?;
Some(
toml::from_str::<ConfigOverrides>(&content)
.map_err(|e| format!("parse config.toml: {}", e))?,
)
} else {
None
};
Ok(DotCarProject {
path: car_dir.to_path_buf(),
identity,
knowledge,
rubrics,
policies,
config_overrides,
})
}
pub fn save_knowledge(car_dir: &Path, entries: &[KnowledgeEntry]) -> Result<usize, String> {
use std::io::Write;
let knowledge_dir = car_dir.join("knowledge");
std::fs::create_dir_all(&knowledge_dir).map_err(|e| format!("create knowledge/: {}", e))?;
let mut count = 0;
for entry in entries {
let filename = match entry.category.as_str() {
"gotcha" => "gotchas.jsonl",
"anti_pattern" => "anti-patterns.jsonl",
"decision" => "decisions.jsonl",
"pattern" => "patterns.jsonl",
_ => "facts.jsonl",
};
let path = knowledge_dir.join(filename);
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("open {}: {}", path.display(), e))?;
let json = serde_json::to_string(entry).map_err(|e| format!("serialize entry: {}", e))?;
writeln!(file, "{}", json).map_err(|e| format!("write {}: {}", path.display(), e))?;
count += 1;
}
Ok(count)
}
pub fn scaffold_project(target_dir: &Path) -> Result<PathBuf, String> {
let car_dir = target_dir.join(DOT_CAR_DIR);
if car_dir.exists() {
return Err(format!("{} already exists", car_dir.display()));
}
std::fs::create_dir_all(car_dir.join("knowledge"))
.map_err(|e| format!("create .car/knowledge: {}", e))?;
std::fs::create_dir_all(car_dir.join("rubrics"))
.map_err(|e| format!("create .car/rubrics: {}", e))?;
std::fs::create_dir_all(car_dir.join("skills"))
.map_err(|e| format!("create .car/skills: {}", e))?;
std::fs::write(car_dir.join("identity.md"), IDENTITY_TEMPLATE)
.map_err(|e| format!("write identity.md: {}", e))?;
for (filename, schema) in &[
("facts.jsonl", FACTS_SCHEMA),
("gotchas.jsonl", GOTCHAS_SCHEMA),
("anti-patterns.jsonl", ANTI_PATTERNS_SCHEMA),
("decisions.jsonl", DECISIONS_SCHEMA),
] {
std::fs::write(car_dir.join("knowledge").join(filename), schema)
.map_err(|e| format!("write {}: {}", filename, e))?;
}
std::fs::write(car_dir.join("config.toml"), CONFIG_TEMPLATE)
.map_err(|e| format!("write config.toml: {}", e))?;
std::fs::write(car_dir.join("policies.json"), "[]")
.map_err(|e| format!("write policies.json: {}", e))?;
std::fs::write(car_dir.join(".gitignore"), GITIGNORE_TEMPLATE)
.map_err(|e| format!("write .gitignore: {}", e))?;
Ok(car_dir)
}
impl KnowledgeEntry {
pub fn to_fact_metadata(&self) -> FactMetadata {
FactMetadata {
confidence: self.confidence.clone(),
provenance: self
.provenance
.iter()
.map(|p| Provenance {
source: p.source.clone(),
reference: p.reference.clone(),
date: p
.date
.as_ref()
.and_then(|d| d.parse::<DateTime<Utc>>().ok()),
})
.collect(),
affected_files: self.affected_files.clone(),
tags: self.tags.clone(),
category: if self.category.is_empty() {
self.entry_type.clone()
} else {
self.category.clone()
},
usage_count: 0,
helpful_count: 0,
outdated_reports: 0,
tenant_id: None,
}
}
pub fn from_fact(fact_id: &str, _key: &str, value: &str, metadata: &FactMetadata) -> Self {
Self {
id: fact_id.to_string(),
entry_type: metadata.category.clone(),
fact: value.to_string(),
recommendation: String::new(),
confidence: metadata.confidence.clone(),
provenance: metadata
.provenance
.iter()
.map(|p| ProvenanceEntry {
source: p.source.clone(),
reference: p.reference.clone(),
date: p.date.map(|d| d.to_rfc3339()),
})
.collect(),
tags: metadata.tags.clone(),
affected_files: metadata.affected_files.clone(),
category: metadata.category.clone(),
}
}
}
const IDENTITY_TEMPLATE: &str = r#"# Project Identity
<!-- This file is loaded as the agent's project context (layer 1). -->
<!-- Describe your project, team, and key technical decisions. -->
## Project
<!-- What is this project? What does it do? -->
## Tech Stack
<!-- Languages, frameworks, databases, infrastructure -->
## Team Conventions
<!-- Coding standards, PR process, naming conventions -->
## Key Decisions
<!-- Important architectural decisions and their rationale -->
"#;
const FACTS_SCHEMA: &str = r#"# Schema: facts.jsonl — General project facts
# Each line is a JSON object with: id, type, fact, recommendation, confidence, provenance[], tags[], affectedFiles[], category
"#;
const GOTCHAS_SCHEMA: &str = r#"# Schema: gotchas.jsonl — Known pitfalls and edge cases
# Each line is a JSON object with: id, type, fact, recommendation, confidence, provenance[], tags[], affectedFiles[], category
"#;
const ANTI_PATTERNS_SCHEMA: &str = r#"# Schema: anti-patterns.jsonl — Things to avoid
# Each line is a JSON object with: id, type, fact, recommendation, confidence, provenance[], tags[], affectedFiles[], category
"#;
const DECISIONS_SCHEMA: &str = r#"# Schema: decisions.jsonl — Architectural decisions with rationale
# Each line is a JSON object with: id, type, fact, recommendation, confidence, provenance[], tags[], affectedFiles[], category
"#;
const CONFIG_TEMPLATE: &str = r#"# CAR project configuration
# These values override the defaults in MemgineConfig.
# token_budget = 8000
# conversation_keep_recent = 6
# compaction_batch_size = 8
# environment_max = 5
# max_skills_in_context = 6
# routing_prefer_local = true
# routing_quality_first_cold_start = true
# routing_bootstrap_min_task_observations = 8
# routing_bootstrap_quality_floor = 0.8
# preferred_generation_model = "vllm-mlx/mlx-community_gemma-4-26B-A4B-it"
# preferred_embedding_model = "Qwen3-Embedding-0.6B"
# preferred_classification_model = "Qwen3-0.6B"
# speech_prefer_local = true
# speech_allow_remote_fallback = true
# speech_preferred_local_stt = "Parakeet-TDT-0.6B-v3-MLX"
# speech_preferred_local_tts = "Qwen3-TTS-12Hz-1.7B-Base-5bit"
# # Optional Kokoro variants:
# # speech_preferred_local_tts = "Kokoro-82M-bf16"
# # speech_preferred_local_tts = "Kokoro-82M-6bit"
# speech_preferred_remote_stt = "scribe_v1"
# speech_preferred_remote_tts = "eleven_flash_v2_5"
"#;
const GITIGNORE_TEMPLATE: &str = r#"# User-local state (not shared with team)
*.local
embeddings/
profiles/
"#;
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn discover_from_nested_dir() {
let tmp = TempDir::new().unwrap();
let car_dir = tmp.path().join(".car");
std::fs::create_dir(&car_dir).unwrap();
let nested = tmp.path().join("src").join("deep").join("nested");
std::fs::create_dir_all(&nested).unwrap();
let found = discover_project(&nested).unwrap();
assert_eq!(found, car_dir);
}
#[test]
fn discover_returns_none_without_car_dir() {
let tmp = TempDir::new().unwrap();
assert!(discover_project(tmp.path()).is_none());
}
#[test]
fn scaffold_creates_structure() {
let tmp = TempDir::new().unwrap();
let car_dir = scaffold_project(tmp.path()).unwrap();
assert!(car_dir.join("identity.md").exists());
assert!(car_dir.join("knowledge/facts.jsonl").exists());
assert!(car_dir.join("knowledge/gotchas.jsonl").exists());
assert!(car_dir.join("knowledge/anti-patterns.jsonl").exists());
assert!(car_dir.join("knowledge/decisions.jsonl").exists());
assert!(car_dir.join("rubrics").is_dir());
assert!(car_dir.join("skills").is_dir());
assert!(car_dir.join("config.toml").exists());
assert!(car_dir.join("policies.json").exists());
assert!(car_dir.join(".gitignore").exists());
}
#[test]
fn scaffold_fails_if_exists() {
let tmp = TempDir::new().unwrap();
scaffold_project(tmp.path()).unwrap();
assert!(scaffold_project(tmp.path()).is_err());
}
#[test]
fn load_empty_project() {
let tmp = TempDir::new().unwrap();
scaffold_project(tmp.path()).unwrap();
let project = load_project(&tmp.path().join(".car")).unwrap();
assert!(project.identity.is_some());
assert!(project.knowledge.is_empty()); assert!(project.rubrics.is_empty());
assert!(project.policies.is_empty());
}
#[test]
fn load_knowledge_from_jsonl() {
let tmp = TempDir::new().unwrap();
scaffold_project(tmp.path()).unwrap();
let fact = r#"{"id":"f1","type":"code_quirk","fact":"Port 3100 for API.","recommendation":"Check .env","confidence":"high","provenance":[],"tags":["config"],"affectedFiles":["src/server.ts"],"category":"fact"}"#;
std::fs::write(
tmp.path().join(".car/knowledge/facts.jsonl"),
format!("# Schema comment\n{}\n", fact),
)
.unwrap();
let project = load_project(&tmp.path().join(".car")).unwrap();
assert_eq!(project.knowledge.len(), 1);
assert_eq!(project.knowledge[0].id, "f1");
assert_eq!(project.knowledge[0].affected_files, vec!["src/server.ts"]);
}
#[test]
fn save_and_reload_knowledge() {
let tmp = TempDir::new().unwrap();
scaffold_project(tmp.path()).unwrap();
let entries = vec![KnowledgeEntry {
id: "g1".into(),
entry_type: "gotcha".into(),
fact: "Always run migrations before tests.".into(),
recommendation: "Add migration check to CI.".into(),
confidence: "high".into(),
provenance: vec![],
tags: vec!["testing".into()],
affected_files: vec!["tests/**".into()],
category: "gotcha".into(),
}];
let saved = save_knowledge(&tmp.path().join(".car"), &entries).unwrap();
assert_eq!(saved, 1);
let content =
std::fs::read_to_string(tmp.path().join(".car/knowledge/gotchas.jsonl")).unwrap();
assert!(content.contains("migrations"));
let project = load_project(&tmp.path().join(".car")).unwrap();
assert_eq!(project.knowledge.len(), 1);
assert_eq!(project.knowledge[0].id, "g1");
}
#[test]
fn config_overrides_apply() {
let overrides = ConfigOverrides {
token_budget: Some(16000),
conversation_keep_recent: Some(10),
..Default::default()
};
let config = overrides.apply(MemgineConfig::default());
assert_eq!(config.token_budget, 16000);
assert_eq!(config.conversation_keep_recent, 10);
assert_eq!(config.environment_max, 5); }
#[test]
fn load_config_from_toml() {
let tmp = TempDir::new().unwrap();
scaffold_project(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(".car/config.toml"),
"token_budget = 16000\nconversation_keep_recent = 12\nrouting_prefer_local = false\nrouting_bootstrap_quality_floor = 0.9\npreferred_generation_model = \"vllm-mlx/mlx-community_gemma-4-26B-A4B-it\"\nspeech_prefer_local = false\nspeech_preferred_local_tts = \"Kokoro-82M-6bit\"\n",
).unwrap();
let project = load_project(&tmp.path().join(".car")).unwrap();
let overrides = project.config_overrides.unwrap();
assert_eq!(overrides.token_budget, Some(16000));
assert_eq!(overrides.conversation_keep_recent, Some(12));
assert_eq!(overrides.routing_prefer_local, Some(false));
assert_eq!(overrides.routing_bootstrap_quality_floor, Some(0.9));
assert_eq!(
overrides.preferred_generation_model.as_deref(),
Some("vllm-mlx/mlx-community_gemma-4-26B-A4B-it")
);
assert_eq!(overrides.speech_prefer_local, Some(false));
assert_eq!(
overrides.speech_preferred_local_tts.as_deref(),
Some("Kokoro-82M-6bit")
);
}
#[test]
fn knowledge_entry_to_fact_metadata() {
let entry = KnowledgeEntry {
id: "f1".into(),
entry_type: "gotcha".into(),
fact: "test".into(),
recommendation: "fix it".into(),
confidence: "high".into(),
provenance: vec![ProvenanceEntry {
source: "user".into(),
reference: "PR #42".into(),
date: Some("2026-03-28T00:00:00Z".into()),
}],
tags: vec!["testing".into()],
affected_files: vec!["src/**".into()],
category: "gotcha".into(),
};
let meta = entry.to_fact_metadata();
assert_eq!(meta.confidence, "high");
assert_eq!(meta.affected_files, vec!["src/**"]);
assert_eq!(meta.provenance.len(), 1);
assert!(meta.provenance[0].date.is_some());
}
}