use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::state::annotation_state::Annotation;
use crate::state::bookmark_state::Bookmark;
use crate::state::{AnnotationState, BookmarkState, ChecklistState};
#[derive(Serialize, Deserialize)]
struct SessionFile {
version: u32,
target_label: String,
annotations: Vec<AnnotationEntry>,
#[serde(default)]
checklist: Option<ChecklistSessionData>,
#[serde(default)]
scores: Vec<ScoreEntry>,
#[serde(default)]
bookmarks: Vec<BookmarkEntry>,
}
#[derive(Serialize, Deserialize)]
struct ChecklistSessionData {
items: Vec<ChecklistItemSession>,
}
#[derive(Serialize, Deserialize)]
struct ChecklistItemSession {
label: String,
key: char,
checked: bool,
note: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct AnnotationEntry {
file_path: String,
#[serde(default)]
old_start: Option<u32>,
#[serde(default)]
old_end: Option<u32>,
#[serde(default)]
new_start: Option<u32>,
#[serde(default)]
new_end: Option<u32>,
#[serde(default)]
line_start: Option<u32>,
#[serde(default)]
line_end: Option<u32>,
comment: String,
created_at: String,
}
#[derive(Serialize, Deserialize)]
struct BookmarkEntry {
file_path: String,
line: u32,
is_new_line: bool,
label: Option<char>,
}
#[derive(Serialize, Deserialize)]
struct ScoreEntry {
file_path: String,
old_start: Option<u32>,
old_end: Option<u32>,
new_start: Option<u32>,
new_end: Option<u32>,
score: u8,
created_at: String,
}
fn session_dir(repo_path: &Path) -> PathBuf {
repo_path.join(".mdiff")
}
fn session_file(repo_path: &Path, target_label: &str) -> PathBuf {
let sanitized = target_label.replace(['/', '\\', ':', ' '], "_");
session_dir(repo_path).join(format!("session_{sanitized}.json"))
}
fn ensure_gitignore(repo_path: &Path) {
let gitignore_path = repo_path.join(".gitignore");
let entry = ".mdiff/";
if let Ok(contents) = fs::read_to_string(&gitignore_path) {
if contents.lines().any(|line| line.trim() == entry) {
return;
}
if let Ok(mut f) = fs::OpenOptions::new().append(true).open(&gitignore_path) {
if !contents.ends_with('\n') {
let _ = writeln!(f);
}
let _ = writeln!(f, "{entry}");
}
} else {
let _ = fs::write(&gitignore_path, format!("{entry}\n"));
}
}
pub fn load_session_data(
repo_path: &Path,
target_label: &str,
) -> (AnnotationState, Option<ChecklistState>, BookmarkState) {
let path = session_file(repo_path, target_label);
let mut annotations_state = AnnotationState::default();
let Ok(contents) = fs::read_to_string(&path) else {
return (annotations_state, None, BookmarkState::new());
};
let Ok(session) = serde_json::from_str::<SessionFile>(&contents) else {
return (annotations_state, None, BookmarkState::new());
};
if !(session.version == 1
|| session.version == 2
|| session.version == 3
|| session.version == 4)
|| session.target_label != target_label
{
return (annotations_state, None, BookmarkState::new());
}
for entry in session.annotations {
let (old_range, new_range) = if session.version == 1 {
let ls = entry.line_start.unwrap_or(1);
let le = entry.line_end.unwrap_or(ls);
(None, Some((ls, le)))
} else {
let old_range = entry.old_start.zip(entry.old_end);
let new_range = entry.new_start.zip(entry.new_end);
(old_range, new_range)
};
annotations_state.add(Annotation {
anchor: crate::state::annotation_state::LineAnchor {
file_path: entry.file_path,
old_range,
new_range,
},
comment: entry.comment,
created_at: entry.created_at,
category: crate::state::annotation_state::AnnotationCategory::Suggestion,
severity: crate::state::annotation_state::AnnotationSeverity::Minor,
});
}
let checklist_state = session.checklist.map(|checklist_data| {
let items = checklist_data
.items
.into_iter()
.map(|item| crate::state::ChecklistItem {
label: item.label,
key: item.key,
checked: item.checked,
note: item.note,
})
.collect();
ChecklistState {
items,
selected: 0,
panel_open: false,
}
});
for entry in session.scores {
let old_range = entry.old_start.zip(entry.old_end);
let new_range = entry.new_start.zip(entry.new_end);
annotations_state.set_score(crate::state::annotation_state::LineScore {
file_path: entry.file_path,
old_range,
new_range,
score: entry.score,
created_at: entry.created_at,
});
}
let mut bookmark_state = BookmarkState::new();
for entry in session.bookmarks {
bookmark_state.bookmarks.push(Bookmark::new(
entry.file_path,
entry.line,
entry.is_new_line,
entry.label,
));
}
(annotations_state, checklist_state, bookmark_state)
}
pub fn save_session_data(
repo_path: &Path,
target_label: &str,
annotations: &AnnotationState,
checklist: Option<&ChecklistState>,
bookmarks: &BookmarkState,
) {
let dir = session_dir(repo_path);
if fs::create_dir_all(&dir).is_err() {
return;
}
ensure_gitignore(repo_path);
let entries: Vec<AnnotationEntry> = annotations
.all_sorted()
.into_iter()
.map(|a| AnnotationEntry {
file_path: a.anchor.file_path.clone(),
old_start: a.anchor.old_range.map(|(s, _)| s),
old_end: a.anchor.old_range.map(|(_, e)| e),
new_start: a.anchor.new_range.map(|(s, _)| s),
new_end: a.anchor.new_range.map(|(_, e)| e),
line_start: None,
line_end: None,
comment: a.comment.clone(),
created_at: a.created_at.clone(),
})
.collect();
let checklist_data = checklist.map(|cl| ChecklistSessionData {
items: cl
.items
.iter()
.map(|item| ChecklistItemSession {
label: item.label.clone(),
key: item.key,
checked: item.checked,
note: item.note.clone(),
})
.collect(),
});
let score_entries: Vec<ScoreEntry> = annotations
.all_scores_sorted()
.into_iter()
.map(|s| ScoreEntry {
file_path: s.file_path.clone(),
old_start: s.old_range.map(|(s, _)| s),
old_end: s.old_range.map(|(_, e)| e),
new_start: s.new_range.map(|(s, _)| s),
new_end: s.new_range.map(|(_, e)| e),
score: s.score,
created_at: s.created_at.clone(),
})
.collect();
let bookmark_entries: Vec<BookmarkEntry> = bookmarks
.bookmarks
.iter()
.map(|b| BookmarkEntry {
file_path: b.file_path.clone(),
line: b.line,
is_new_line: b.is_new_line,
label: b.label,
})
.collect();
let has_extra = checklist_data.is_some() || !bookmark_entries.is_empty();
let session = SessionFile {
version: if has_extra { 4 } else { 2 },
target_label: target_label.to_string(),
annotations: entries,
checklist: checklist_data,
scores: score_entries,
bookmarks: bookmark_entries,
};
if let Ok(json) = serde_json::to_string_pretty(&session) {
let _ = fs::write(session_file(repo_path, target_label), json);
}
}