use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::app::CommentScope;
pub fn worktree_dir(repo_root: &Path) -> PathBuf {
repo_root.join(".turboreview")
}
pub fn commit_dir(repo_root: &Path, sha: &str) -> PathBuf {
repo_root.join(".turboreview").join("commits").join(sha)
}
pub fn scope_dir(repo_root: &Path, scope: &CommentScope) -> PathBuf {
match scope {
CommentScope::Worktree => worktree_dir(repo_root),
CommentScope::Commit(sha) => commit_dir(repo_root, sha),
}
}
#[derive(Serialize)]
struct LogEntry<'a> {
path: &'a str,
line: u32,
scope: &'a str,
date: String,
action: &'a str,
}
#[derive(Serialize, Deserialize, Default)]
struct Config {
theme: String, }
pub fn load_theme(repo_root: &Path) -> crate::theme::Theme {
let path = worktree_dir(repo_root).join("config.json");
let Ok(bytes) = std::fs::read(&path) else {
return crate::theme::Theme::Dark;
};
let cfg: Config = serde_json::from_slice(&bytes).unwrap_or_default();
match cfg.theme.as_str() {
"light" => crate::theme::Theme::Light,
_ => crate::theme::Theme::Dark,
}
}
pub fn save_theme(repo_root: &Path, theme: crate::theme::Theme) -> Result<()> {
let dir = worktree_dir(repo_root);
std::fs::create_dir_all(&dir)?;
let cfg = Config {
theme: match theme {
crate::theme::Theme::Light => "light".into(),
_ => "dark".into(),
},
};
std::fs::write(dir.join("config.json"), serde_json::to_vec_pretty(&cfg)?)?;
Ok(())
}
pub fn append_comment_log(
repo_root: &Path,
path: &Path,
line: u32,
scope: &str,
action: &str,
) -> Result<()> {
let dir = repo_root.join(".turboreview");
std::fs::create_dir_all(&dir)?;
let path_str = path.to_string_lossy();
let entry = LogEntry {
path: &path_str,
line,
scope,
date: crate::git::format_datetime(now_secs()),
action,
};
let mut line_json = serde_json::to_string(&entry)?;
line_json.push('\n');
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("comment-log.jsonl"))?;
f.write_all(line_json.as_bytes())?;
Ok(())
}
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn worktree_dir_is_dot_turboreview() {
let root = PathBuf::from("/my/repo");
assert_eq!(worktree_dir(&root), PathBuf::from("/my/repo/.turboreview"));
}
#[test]
fn commit_dir_is_commits_slash_sha() {
let root = PathBuf::from("/my/repo");
assert_eq!(
commit_dir(&root, "abc123"),
PathBuf::from("/my/repo/.turboreview/commits/abc123")
);
}
#[test]
fn save_then_load_theme_round_trips_light() {
let dir = tempdir().unwrap();
let root = dir.path();
save_theme(root, crate::theme::Theme::Light).unwrap();
let loaded = load_theme(root);
assert_eq!(loaded, crate::theme::Theme::Light);
}
#[test]
fn save_then_load_theme_round_trips_dark() {
let dir = tempdir().unwrap();
let root = dir.path();
save_theme(root, crate::theme::Theme::Dark).unwrap();
let loaded = load_theme(root);
assert_eq!(loaded, crate::theme::Theme::Dark);
}
#[test]
fn load_theme_missing_file_returns_dark() {
let dir = tempdir().unwrap();
let root = dir.path();
let loaded = load_theme(root);
assert_eq!(loaded, crate::theme::Theme::Dark);
}
#[test]
fn append_comment_log_writes_two_valid_json_lines() {
let dir = tempdir().unwrap();
let root = dir.path();
append_comment_log(root, Path::new("src/main.rs"), 42, "worktree", "set").unwrap();
append_comment_log(
root,
Path::new("src/lib.rs"),
10,
"commit:deadbeef",
"remove",
)
.unwrap();
let log_path = root.join(".turboreview/comment-log.jsonl");
assert!(log_path.exists(), "log file should exist");
let contents = std::fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2, "should have 2 lines");
let entry1: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(entry1["path"], "src/main.rs");
assert_eq!(entry1["line"], 42);
assert_eq!(entry1["scope"], "worktree");
assert_eq!(entry1["action"], "set");
let date_str = entry1["date"].as_str().expect("date should be a string");
assert_eq!(
date_str.len(),
19,
"date field must be YYYY-MM-DD HH:MM:SS (19 chars): {}",
date_str
);
assert!(
date_str.contains(' '),
"date field must contain a space separating date and time"
);
assert_eq!(
date_str.chars().filter(|&c| c == ':').count(),
2,
"date field must have two colons for HH:MM:SS"
);
let entry2: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(entry2["path"], "src/lib.rs");
assert_eq!(entry2["line"], 10);
assert_eq!(entry2["scope"], "commit:deadbeef");
assert_eq!(entry2["action"], "remove");
}
}