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(Clone, Serialize, Deserialize, Default, Debug, PartialEq)]
pub struct AdapterConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Clone, Serialize, Deserialize, Default, Debug, PartialEq)]
pub struct RemoteConfig {
#[serde(default)]
pub host: String,
#[serde(default)]
pub port: u16,
#[serde(default)]
pub attach_commands: Vec<String>,
}
impl RemoteConfig {
pub fn is_set(&self) -> bool {
!self.attach_commands.is_empty() || (!self.host.is_empty() && self.port != 0)
}
pub fn commands(&self) -> Vec<String> {
if !self.attach_commands.is_empty() {
self.attach_commands.clone()
} else {
vec![format!("gdb-remote {}:{}", self.host, self.port)]
}
}
}
#[derive(Clone, Serialize, Deserialize, Default, Debug, PartialEq)]
pub struct DebugConfig {
#[serde(default)]
pub adapter: AdapterConfig,
#[serde(default)]
pub build: String,
#[serde(default)]
pub program: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub cwd: String,
#[serde(default)]
pub source_map: Vec<(String, String)>,
#[serde(default)]
pub remote: RemoteConfig,
}
#[derive(Serialize, Deserialize, Default)]
struct Config {
theme: String, #[serde(default)]
split_diff: bool, #[serde(default)]
debug: DebugConfig, #[serde(default)]
coverage_file: String, #[serde(default)]
coverage_command: String, }
fn load_config(repo_root: &Path) -> Config {
let path = worktree_dir(repo_root).join("config.json");
let Ok(bytes) = std::fs::read(&path) else {
return Config::default();
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
fn save_config(repo_root: &Path, cfg: &Config) -> Result<()> {
let dir = worktree_dir(repo_root);
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("config.json"), serde_json::to_vec_pretty(cfg)?)?;
Ok(())
}
pub fn load_theme(repo_root: &Path) -> crate::theme::Theme {
match load_config(repo_root).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 mut cfg = load_config(repo_root);
cfg.theme = match theme {
crate::theme::Theme::Light => "light".into(),
_ => "dark".into(),
};
save_config(repo_root, &cfg)
}
pub fn load_split(repo_root: &Path) -> bool {
load_config(repo_root).split_diff
}
pub fn save_split(repo_root: &Path, split: bool) -> Result<()> {
let mut cfg = load_config(repo_root);
cfg.split_diff = split;
save_config(repo_root, &cfg)
}
pub fn load_debug_config(repo_root: &Path) -> DebugConfig {
load_config(repo_root).debug
}
pub fn load_coverage(repo_root: &Path) -> Result<crate::coverage::Coverage> {
let cfg = load_config(repo_root);
if cfg.coverage_file.trim().is_empty() {
anyhow::bail!("no coverage file set (config: \"coverage_file\")");
}
let path = repo_root.join(&cfg.coverage_file);
let text = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
Ok(crate::coverage::Coverage::parse_lcov(&text))
}
pub fn run_coverage(repo_root: &Path) -> Result<crate::coverage::Coverage> {
let cmd = load_config(repo_root).coverage_command;
if cmd.trim().is_empty() {
anyhow::bail!("no coverage command set (config: \"coverage_command\")");
}
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.current_dir(repo_root)
.output()
.map_err(|e| anyhow::anyhow!("running coverage command: {e}"))?;
if !out.status.success() {
anyhow::bail!(
"coverage command failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
load_coverage(repo_root)
}
const ARCHIVE_DAYS: i64 = 14;
pub fn archive_path(repo_root: &Path) -> PathBuf {
worktree_dir(repo_root)
.join("archive")
.join("comments-archive.jsonl")
}
pub fn append_archive(
repo_root: &Path,
comments: &[crate::comments::Comment],
) -> anyhow::Result<()> {
if comments.is_empty() {
return Ok(());
}
let path = archive_path(repo_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
for c in comments {
let mut line = serde_json::to_string(c)?;
line.push('\n');
f.write_all(line.as_bytes())?;
}
Ok(())
}
pub fn archive_cutoff_secs(now: i64) -> i64 {
now - ARCHIVE_DAYS * 86400
}
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(())
}
pub 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 remote_config_commands_and_is_set() {
let mut r = RemoteConfig::default();
assert!(!r.is_set());
r.host = "localhost".into();
r.port = 1234;
assert!(r.is_set());
assert_eq!(r.commands(), vec!["gdb-remote localhost:1234".to_string()]);
r.attach_commands = vec!["process connect connect://x:9".into()];
assert_eq!(r.commands(), vec!["process connect connect://x:9".to_string()]);
assert!(r.is_set());
}
#[test]
fn load_coverage_reads_configured_lcov() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(worktree_dir(root)).unwrap();
std::fs::write(
worktree_dir(root).join("config.json"),
br#"{"theme":"dark","coverage_file":"cov/lcov.info"}"#,
)
.unwrap();
std::fs::create_dir_all(root.join("cov")).unwrap();
std::fs::write(
root.join("cov/lcov.info"),
"SF:src/x.rs\nDA:1,4\nDA:2,0\nend_of_record\n",
)
.unwrap();
let cov = load_coverage(root).unwrap();
use crate::coverage::LineCov;
assert_eq!(
cov.line_cov(std::path::Path::new("src/x.rs"), 1),
LineCov::Covered
);
assert_eq!(
cov.line_cov(std::path::Path::new("src/x.rs"), 2),
LineCov::Uncovered
);
}
#[test]
fn load_coverage_errors_without_config() {
let dir = tempdir().unwrap();
assert!(load_coverage(dir.path()).is_err());
}
#[test]
fn debug_config_round_trips_and_preserves_other_fields() {
let dir = tempdir().unwrap();
let root = dir.path();
save_theme(root, crate::theme::Theme::Light).unwrap();
save_split(root, true).unwrap();
let mut cfg = load_config(root);
cfg.debug = DebugConfig {
adapter: AdapterConfig {
command: "lldb-dap".into(),
args: vec!["--port".into(), "0".into()],
},
build: "cargo build".into(),
program: "target/debug/app".into(),
args: vec!["--flag".into()],
cwd: ".".into(),
source_map: vec![("/old".into(), "/new".into())],
remote: RemoteConfig::default(),
};
save_config(root, &cfg).unwrap();
let loaded = load_debug_config(root);
assert_eq!(loaded.adapter.command, "lldb-dap");
assert_eq!(loaded.build, "cargo build");
assert_eq!(loaded.source_map, vec![("/old".into(), "/new".into())]);
assert_eq!(load_theme(root), crate::theme::Theme::Light);
assert!(load_split(root));
}
#[test]
fn missing_debug_block_loads_default() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(worktree_dir(root)).unwrap();
std::fs::write(
worktree_dir(root).join("config.json"),
br#"{"theme":"dark"}"#,
)
.unwrap();
assert_eq!(load_debug_config(root), DebugConfig::default());
}
#[test]
fn split_round_trips() {
let dir = tempdir().unwrap();
let root = dir.path();
assert!(!load_split(root)); save_split(root, true).unwrap();
assert!(load_split(root));
save_split(root, false).unwrap();
assert!(!load_split(root));
}
#[test]
fn saving_theme_preserves_split() {
let dir = tempdir().unwrap();
let root = dir.path();
save_split(root, true).unwrap();
save_theme(root, crate::theme::Theme::Light).unwrap();
assert!(load_split(root));
assert_eq!(load_theme(root), crate::theme::Theme::Light);
}
#[test]
fn saving_split_preserves_theme() {
let dir = tempdir().unwrap();
let root = dir.path();
save_theme(root, crate::theme::Theme::Light).unwrap();
save_split(root, true).unwrap();
assert_eq!(load_theme(root), crate::theme::Theme::Light);
assert!(load_split(root));
}
#[test]
fn old_theme_only_config_still_loads() {
let dir = tempdir().unwrap();
let root = dir.path();
let cfgdir = worktree_dir(root);
std::fs::create_dir_all(&cfgdir).unwrap();
std::fs::write(cfgdir.join("config.json"), br#"{"theme":"light"}"#).unwrap();
assert_eq!(load_theme(root), crate::theme::Theme::Light);
assert!(!load_split(root)); }
#[test]
fn archive_path_is_under_dot_turboreview_archive() {
let root = PathBuf::from("/my/repo");
let p = archive_path(&root);
assert_eq!(
p,
PathBuf::from("/my/repo/.turboreview/archive/comments-archive.jsonl")
);
}
#[test]
fn append_archive_writes_one_json_line_per_comment() {
use crate::comments::{Comment, CommentStatus};
let dir = tempdir().unwrap();
let root = dir.path();
let comments = vec![
Comment {
file: std::path::PathBuf::from("a.rs"),
line: 1,
hunk: "@@".to_string(),
text: "note one".to_string(),
line_text: "fn a()".to_string(),
context_before: vec![],
context_after: vec![],
orig_line: 1,
stale: false,
status: CommentStatus::Resolved,
response: None,
updated: 1000,
debug_snapshot: None,
},
Comment {
file: std::path::PathBuf::from("b.rs"),
line: 5,
hunk: "@@".to_string(),
text: "note two".to_string(),
line_text: "fn b()".to_string(),
context_before: vec![],
context_after: vec![],
orig_line: 5,
stale: false,
status: CommentStatus::Resolved,
response: None,
updated: 2000,
debug_snapshot: None,
},
];
append_archive(root, &comments).unwrap();
let archive = archive_path(root);
assert!(archive.exists(), "archive file must exist");
let contents = std::fs::read_to_string(&archive).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2, "must write one line per comment");
let v1: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(v1["text"], "note one");
let v2: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(v2["text"], "note two");
}
#[test]
fn append_archive_is_append_only() {
use crate::comments::{Comment, CommentStatus};
let dir = tempdir().unwrap();
let root = dir.path();
let c1 = Comment {
file: std::path::PathBuf::from("a.rs"),
line: 1,
hunk: "@@".to_string(),
text: "first".to_string(),
line_text: "".to_string(),
context_before: vec![],
context_after: vec![],
orig_line: 1,
stale: false,
status: CommentStatus::Resolved,
response: None,
updated: 100,
debug_snapshot: None,
};
let c2 = Comment {
file: std::path::PathBuf::from("b.rs"),
line: 2,
hunk: "@@".to_string(),
text: "second".to_string(),
line_text: "".to_string(),
context_before: vec![],
context_after: vec![],
orig_line: 2,
stale: false,
status: CommentStatus::Resolved,
response: None,
updated: 200,
debug_snapshot: None,
};
append_archive(root, &[c1]).unwrap();
append_archive(root, &[c2]).unwrap();
let contents = std::fs::read_to_string(archive_path(root)).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2, "second call must append, not overwrite");
let v1: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(v1["text"], "first");
let v2: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(v2["text"], "second");
}
#[test]
fn append_archive_errors_when_path_unwritable() {
use crate::comments::{Comment, CommentStatus};
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".turboreview"), b"x").unwrap();
let c = Comment {
file: std::path::PathBuf::from("a.rs"),
line: 1,
hunk: "@@".to_string(),
text: "test".to_string(),
line_text: "fn a()".to_string(),
context_before: vec![],
context_after: vec![],
orig_line: 1,
stale: false,
status: CommentStatus::Resolved,
response: None,
updated: 1000,
debug_snapshot: None,
};
let res = append_archive(dir.path(), &[c]);
assert!(
res.is_err(),
"append_archive must error when the dir can't be created"
);
}
#[test]
fn append_archive_empty_slice_does_nothing() {
use crate::comments::Comment;
let dir = tempdir().unwrap();
let root = dir.path();
append_archive(root, &[] as &[Comment]).unwrap();
assert!(
!archive_path(root).exists(),
"empty slice must not create archive file"
);
}
#[test]
fn archive_cutoff_secs_is_14_days_before_now() {
let now = 1_000_000_i64;
let cutoff = archive_cutoff_secs(now);
assert_eq!(cutoff, now - 14 * 86400);
}
#[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");
}
}