use crate::error::{JjjError, Result};
use crate::jj::JjClient;
use crate::models::{Event, ProblemStatus, ProjectConfig, SolutionStatus};
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
mod critiques;
mod events;
mod milestones;
mod problems;
mod solutions;
pub(super) fn atomic_write(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let tmp = path.with_extension(format!("md.{}.{}.tmp", std::process::id(), nanos));
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub const META_BOOKMARK: &str = "jjj";
pub(super) const CONFIG_FILE: &str = "config.toml";
pub(super) const PROBLEMS_DIR: &str = "problems";
pub(super) const SOLUTIONS_DIR: &str = "solutions";
pub(super) const CRITIQUES_DIR: &str = "critiques";
pub(super) const MILESTONES_DIR: &str = "milestones";
pub struct MetadataStore {
meta_path: PathBuf,
events_path: PathBuf,
pub jj_client: JjClient,
pending_events: RefCell<Vec<Event>>,
}
fn load_global_config() -> ProjectConfig {
let config_dir = global_config_dir().join("config.toml");
if !config_dir.exists() {
return ProjectConfig::default();
}
std::fs::read_to_string(&config_dir)
.ok()
.and_then(|s| toml::from_str(&s).ok())
.unwrap_or_default()
}
fn global_config_dir() -> std::path::PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return std::path::PathBuf::from(xdg).join("jjj");
}
if let Some(home) = std::env::var_os("HOME") {
return std::path::PathBuf::from(home).join(".config").join("jjj");
}
std::path::PathBuf::from(".config").join("jjj")
}
fn merge_config(base: &mut ProjectConfig, project: &ProjectConfig) {
if project.name.is_some() {
base.name = project.name.clone();
}
if !project.default_reviewers.is_empty() {
base.default_reviewers = project.default_reviewers.clone();
}
if !project.settings.is_empty() {
base.settings.extend(project.settings.clone());
}
base.github = project.github.clone();
if project.sync.fetch.is_some() {
base.sync.fetch = project.sync.fetch.clone();
}
if project.sync.push.is_some() {
base.sync.push = project.sync.push.clone();
}
if project.sync.track.is_some() {
base.sync.track = project.sync.track.clone();
}
if project.sync.workspace.is_some() {
base.sync.workspace = project.sync.workspace.clone();
}
if !project.automation.is_empty() {
base.automation = project.automation.clone();
}
}
fn parse_frontmatter<T: serde::de::DeserializeOwned>(content: &str) -> Result<(T, String)> {
let content = content.trim();
if !content.starts_with("---") {
return Err(JjjError::FrontmatterParse {
entity_type: String::new(),
entity_id: String::new(),
message: "File must start with YAML frontmatter (---)".to_string(),
});
}
let rest = &content[3..];
let end_pos = rest
.find("\n---")
.ok_or_else(|| JjjError::FrontmatterParse {
entity_type: String::new(),
entity_id: String::new(),
message: "Missing closing frontmatter delimiter".to_string(),
})?;
let yaml_str = &rest[..end_pos].trim();
let body = rest[end_pos + 4..].trim().to_string();
let frontmatter: T = serde_yml::from_str(yaml_str).map_err(|e| JjjError::FrontmatterParse {
entity_type: String::new(),
entity_id: String::new(),
message: e.to_string(),
})?;
Ok((frontmatter, body))
}
fn add_frontmatter_context(err: JjjError, entity_type: &str, entity_id: &str) -> JjjError {
match err {
JjjError::FrontmatterParse { message, .. } => JjjError::FrontmatterParse {
entity_type: entity_type.to_string(),
entity_id: entity_id.to_string(),
message,
},
other => other,
}
}
fn to_markdown<T: serde::Serialize>(frontmatter: &T, body: &str) -> Result<String> {
let yaml = serde_yml::to_string(frontmatter)?;
Ok(format!("---\n{}---\n\n{}", yaml, body))
}
impl MetadataStore {
pub fn new(jj_client: JjClient) -> Result<Self> {
let repo_root = jj_client.repo_root().to_path_buf();
let meta_path = repo_root.join(".jj").join("jjj-meta");
let events_path = meta_path.join("events.jsonl");
let store = Self {
meta_path,
events_path,
jj_client,
pending_events: RefCell::new(Vec::new()),
};
store.maybe_migrate();
Ok(store)
}
pub fn meta_path(&self) -> &std::path::Path {
&self.meta_path
}
fn maybe_migrate(&self) {
if self.events_path.exists() {
return;
}
let meta_jj = self.meta_path.join(".jj");
if !meta_jj.exists() {
return;
}
let meta_client = match JjClient::with_root(self.meta_path.clone()) {
Ok(c) => c,
Err(_) => return,
};
let descriptions = match meta_client.log_descriptions("::@") {
Ok(d) => d,
Err(_) => return,
};
let events: Vec<Event> = descriptions
.iter()
.flat_map(|d| {
d.lines()
.filter(|l| l.starts_with("jjj: "))
.filter_map(|l| serde_json::from_str(&l["jjj: ".len()..]).ok())
})
.collect();
if events.is_empty() {
return;
}
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.events_path)
{
for event in &events {
if let Ok(line) = event.to_json_line() {
let _ = writeln!(file, "{}", line);
}
}
eprintln!(
"Migrated {} events from commit history to events.jsonl",
events.len()
);
}
}
pub fn init(&self) -> Result<()> {
if self.meta_path.join(CONFIG_FILE).exists() {
return Err(crate::error::JjjError::Validation(
"jjj is already initialized".to_string(),
));
}
self.ensure_meta_dirs()?;
let default_config = ProjectConfig::default();
self.save_config(&default_config)?;
Ok(())
}
pub(super) fn ensure_meta_dirs(&self) -> Result<()> {
fs::create_dir_all(self.meta_path.join(PROBLEMS_DIR))?;
fs::create_dir_all(self.meta_path.join(SOLUTIONS_DIR))?;
fs::create_dir_all(self.meta_path.join(CRITIQUES_DIR))?;
fs::create_dir_all(self.meta_path.join(MILESTONES_DIR))?;
Ok(())
}
pub(super) fn ensure_meta_checkout(&self) -> Result<()> {
self.ensure_meta_dirs()
}
pub fn load_config(&self) -> Result<ProjectConfig> {
self.ensure_meta_checkout()?;
let mut config = load_global_config();
let config_path = self.meta_path.join(CONFIG_FILE);
if config_path.exists() {
let content = fs::read_to_string(config_path)?;
let project: ProjectConfig = toml::from_str(&content)?;
merge_config(&mut config, &project);
}
Ok(config)
}
pub fn save_config(&self, config: &ProjectConfig) -> Result<()> {
self.ensure_meta_checkout()?;
let config_path = self.meta_path.join(CONFIG_FILE);
let content = toml::to_string_pretty(config)?;
atomic_write(&config_path, content.as_bytes())?;
Ok(())
}
pub fn can_solve_problem(&self, problem_id: &str) -> Result<(bool, String)> {
let problem = self.load_problem(problem_id)?;
if problem.status == ProblemStatus::Solved {
return Ok((false, "Problem is already solved".to_string()));
}
let solutions = self.list_solutions_for_problem(problem_id)?;
let has_approved = solutions
.iter()
.any(|s| s.status == SolutionStatus::Approved);
if has_approved {
return Ok((true, String::new()));
}
let subproblems = self.list_subproblems(problem_id)?;
if !subproblems.is_empty() {
let all_solved = subproblems
.iter()
.all(|p| p.status == ProblemStatus::Solved);
if all_solved {
return Ok((true, "All subproblems are solved".to_string()));
}
return Ok((
false,
"Not all subproblems are solved and no approved solution exists".to_string(),
));
}
Ok((false, "No approved solution exists".to_string()))
}
pub fn can_approve_solution(&self, solution_id: &str) -> Result<(bool, String)> {
let solution = self.load_solution(solution_id)?;
if solution.is_finalized() {
return Ok((false, format!("Solution is already {:?}", solution.status)));
}
if self.has_valid_critiques(solution_id)? {
return Ok((
false,
"Solution has valid critiques that block approval".to_string(),
));
}
let open_critiques = self.list_open_critiques_for_solution(solution_id)?;
if !open_critiques.is_empty() {
return Ok((
true,
format!(
"Warning: {} open critique(s) remain unaddressed",
open_critiques.len()
),
));
}
Ok((true, String::new()))
}
pub fn commit_changes(&self, _message: &str) -> Result<()> {
use std::io::Write;
let mut pending = self.pending_events.borrow_mut();
if pending.is_empty() {
return Ok(());
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.events_path)
.map_err(JjjError::Io)?;
for event in pending.drain(..) {
match event.to_json_line() {
Ok(line) => {
if let Err(e) = writeln!(file, "{}", line) {
eprintln!("Warning: failed to write event: {}", e);
}
}
Err(err) => {
eprintln!("Warning: failed to serialize event: {}", err);
}
}
}
Ok(())
}
pub fn with_metadata<F, R>(&self, message: &str, operation: F) -> Result<R>
where
F: FnOnce() -> Result<R>,
{
let result = operation()?;
self.commit_changes(message)?;
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Confidence, Priority, ProblemFrontmatter};
use chrono::Utc;
#[test]
fn test_parse_frontmatter() {
let content = r#"---
id: p1
title: Test Problem
status: open
priority: medium
created_at: 2024-01-15T10:30:00Z
updated_at: 2024-01-15T10:30:00Z
---
## Description
This is a test problem.
## Context
Some context here.
"#;
let (frontmatter, body): (ProblemFrontmatter, String) = parse_frontmatter(content).unwrap();
assert_eq!(frontmatter.id, "p1");
assert_eq!(frontmatter.title, "Test Problem");
assert!(body.contains("## Description"));
}
#[test]
fn test_to_markdown() {
let frontmatter = ProblemFrontmatter {
id: "p1".to_string(),
title: "Test".to_string(),
parent_id: None,
status: ProblemStatus::Open,
priority: Priority::default(),
confidence: Confidence::default(),
solution_ids: vec![],
child_ids: vec![],
milestone_id: None,
assignee: None,
created_at: Utc::now(),
updated_at: Utc::now(),
dissolved_reason: None,
github_issue: None,
tags: vec![],
};
let body = "Test description\n";
let result = to_markdown(&frontmatter, body).unwrap();
assert!(result.starts_with("---\n"));
assert!(result.contains("id: p1"));
assert!(result.contains("Test description"));
}
#[test]
fn test_critique_frontmatter_with_reviewer() {
use crate::models::{Critique, CritiqueFrontmatter};
let mut critique = Critique::new(
"c1".to_string(),
"Awaiting review".to_string(),
"s1".to_string(),
);
critique.reviewer = Some("bob".to_string());
let frontmatter = CritiqueFrontmatter::from(&critique);
let body = format!("{}\n", critique.argument);
let markdown = to_markdown(&frontmatter, &body).unwrap();
assert!(markdown.contains("reviewer: bob"));
}
}