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))
}
fn parse_body_sections(body: &str) -> std::collections::HashMap<String, String> {
let mut sections = std::collections::HashMap::new();
let mut current_section = String::new();
let mut current_content = String::new();
for line in body.lines() {
if line.starts_with("## ") {
if !current_section.is_empty() {
sections.insert(current_section.clone(), current_content.trim().to_string());
}
let raw_header = line
.strip_prefix("## ")
.expect("strip_prefix failed after starts_with check");
current_section = normalize_section_header(raw_header);
current_content = String::new();
} else {
current_content.push_str(line);
current_content.push('\n');
}
}
if !current_section.is_empty() {
sections.insert(current_section, current_content.trim().to_string());
}
sections
}
fn normalize_section_header(header: &str) -> String {
let lower = header.to_lowercase();
match lower.as_str() {
"description" => "Description".to_string(),
"context" => "Context".to_string(),
"approach" => "Approach".to_string(),
"trade-offs" | "tradeoffs" | "trade offs" => "Trade-offs".to_string(),
"argument" => "Argument".to_string(),
"evidence" => "Evidence".to_string(),
_ => {
let mut chars = header.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
}
}
}
}
fn build_body(sections: &[(&str, &str)]) -> String {
sections
.iter()
.filter(|(_, content)| !content.is_empty())
.map(|(header, content)| format!("## {}\n\n{}", header, content))
.collect::<Vec<_>>()
.join("\n\n")
}
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)?;
fs::write(config_path, content)?;
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| e.to_commit_suffix().ok())
.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_parse_body_sections() {
let body = r#"## Description
This is the description.
## Context
This is the context.
"#;
let sections = parse_body_sections(body);
assert_eq!(
sections.get("Description").unwrap(),
"This is the description."
);
assert_eq!(sections.get("Context").unwrap(), "This is the context.");
}
#[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 = "## Description\n\nTest description";
let result = to_markdown(&frontmatter, body).unwrap();
assert!(result.starts_with("---\n"));
assert!(result.contains("id: p1"));
assert!(result.contains("## Description"));
}
#[test]
fn test_build_body() {
let sections = vec![
("Description", "Test description"),
("Context", "Test context"),
("Empty", ""),
];
let body = build_body(§ions);
assert!(body.contains("## Description"));
assert!(body.contains("Test description"));
assert!(body.contains("## Context"));
assert!(!body.contains("## Empty")); }
#[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 = build_body(&[
("Argument", &critique.argument),
("Evidence", &critique.evidence),
]);
let markdown = to_markdown(&frontmatter, &body).unwrap();
assert!(markdown.contains("reviewer: bob"));
}
}