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(super) 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,
pub jj_client: JjClient,
pub meta_client: JjClient,
pending_events: RefCell<Vec<Event>>,
}
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 meta_client = JjClient::with_root(meta_path.clone())?;
Ok(Self {
meta_path,
jj_client,
meta_client,
pending_events: RefCell::new(Vec::new()),
})
}
pub fn meta_path(&self) -> &std::path::Path {
&self.meta_path
}
pub fn init(&self) -> Result<()> {
if self.jj_client.bookmark_exists(META_BOOKMARK)? {
return Err(crate::error::JjjError::Validation(
"jjj is already initialized".to_string(),
));
}
let meta_path_str = self
.meta_path
.to_str()
.ok_or_else(|| JjjError::PathError(self.meta_path.clone()))?;
self.jj_client
.execute(&["workspace", "add", meta_path_str, "-r", "root()"])?;
self.meta_client.describe("Initialize jjj metadata")?;
let change_id = self.meta_client.current_change_id()?;
self.jj_client.execute(&[
"--ignore-working-copy",
"bookmark",
"create",
META_BOOKMARK,
"-r",
&change_id,
])?;
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))?;
let default_config = ProjectConfig::default();
self.save_config(&default_config)?;
self.commit_changes("Initialize jjj structure")?;
Ok(())
}
pub(super) fn ensure_meta_checkout(&self) -> Result<()> {
if !self.meta_path.exists() {
let meta_path_str = self
.meta_path
.to_str()
.ok_or_else(|| JjjError::PathError(self.meta_path.clone()))?;
if !self.jj_client.bookmark_exists(META_BOOKMARK)? {
self.jj_client
.execute(&["workspace", "add", meta_path_str, "-r", "root()"])?;
self.meta_client.describe("Initialize jjj metadata")?;
let change_id = self.meta_client.current_change_id()?;
self.jj_client.execute(&[
"--ignore-working-copy",
"bookmark",
"create",
META_BOOKMARK,
"-r",
&change_id,
])?;
} else {
self.jj_client.execute(&[
"workspace",
"add",
meta_path_str,
"-r",
META_BOOKMARK,
])?;
}
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 fn load_config(&self) -> Result<ProjectConfig> {
self.ensure_meta_checkout()?;
let config_path = self.meta_path.join(CONFIG_FILE);
if !config_path.exists() {
return Ok(ProjectConfig::default());
}
let content = fs::read_to_string(config_path)?;
let config: ProjectConfig = toml::from_str(&content)?;
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<()> {
let event_suffix = {
let mut pending = self.pending_events.borrow_mut();
if pending.is_empty() {
String::new()
} else {
let lines: String = pending
.drain(..)
.filter_map(|e| match e.to_commit_suffix() {
Ok(line) => Some(line),
Err(err) => {
eprintln!("Warning: failed to serialize event: {}", err);
None
}
})
.collect::<Vec<_>>()
.join("\n");
format!("\n\n{}", lines)
}
};
let full_message = format!("{}{}", message, event_suffix);
let _ = self.meta_client.execute(&["workspace", "update-stale"]);
self.meta_client.describe(&full_message)?;
self.meta_client.execute(&["new"])?;
let meta_change = self
.meta_client
.execute(&["log", "--no-graph", "-r", "@-", "-T", "commit_id"])?
.trim()
.to_string();
self.jj_client.execute(&[
"--ignore-working-copy",
"bookmark",
"set",
META_BOOKMARK,
"-r",
&meta_change,
"--allow-backwards",
])?;
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"));
}
}