use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
const JOURNAL_FILE: &str = "journal.json";
const MAX_ENTRIES: usize = 50;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalEntry {
pub timestamp: String,
pub entry_type: EntryType,
pub git_branch: Option<String>,
pub git_commit: Option<String>,
pub commit_message: Option<String>,
pub files_changed: Vec<FileChange>,
pub symbols_modified: Vec<String>,
pub user_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntryType {
Commit,
Manual,
Session,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChange {
pub path: String,
pub added: u32,
pub removed: u32,
pub symbols: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Journal {
pub project_name: String,
pub entries: Vec<JournalEntry>,
}
pub struct JournalManager {
project_root: PathBuf,
journal_path: PathBuf,
}
impl JournalManager {
pub fn new(project_root: &Path) -> Self {
let journal_path = project_root.join(".minni").join(JOURNAL_FILE);
Self {
project_root: project_root.to_path_buf(),
journal_path,
}
}
pub fn load(&self) -> Result<Journal> {
if self.journal_path.exists() {
let content = std::fs::read_to_string(&self.journal_path)
.context("Failed to read journal file")?;
serde_json::from_str(&content).context("Failed to parse journal")
} else {
Ok(Journal {
project_name: self.get_project_name(),
entries: vec![],
})
}
}
pub fn save(&self, journal: &Journal) -> Result<()> {
let content = serde_json::to_string_pretty(journal)?;
std::fs::write(&self.journal_path, content)?;
Ok(())
}
pub fn record_commit(&self) -> Result<JournalEntry> {
let mut journal = self.load()?;
let entry = JournalEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
entry_type: EntryType::Commit,
git_branch: self.get_git_branch(),
git_commit: self.get_git_commit(),
commit_message: self.get_commit_message(),
files_changed: self.get_commit_changes()?,
symbols_modified: vec![], user_note: None,
};
journal.entries.push(entry.clone());
if journal.entries.len() > MAX_ENTRIES {
journal.entries = journal
.entries
.split_off(journal.entries.len() - MAX_ENTRIES);
}
self.save(&journal)?;
Ok(entry)
}
pub fn add_note(&self, note: &str) -> Result<JournalEntry> {
let mut journal = self.load()?;
let entry = JournalEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
entry_type: EntryType::Manual,
git_branch: self.get_git_branch(),
git_commit: self.get_git_commit(),
commit_message: None,
files_changed: vec![],
symbols_modified: vec![],
user_note: Some(note.to_string()),
};
journal.entries.push(entry.clone());
if journal.entries.len() > MAX_ENTRIES {
journal.entries = journal
.entries
.split_off(journal.entries.len() - MAX_ENTRIES);
}
self.save(&journal)?;
Ok(entry)
}
pub fn get_recent(&self, count: usize) -> Result<Vec<JournalEntry>> {
let journal = self.load()?;
let start = journal.entries.len().saturating_sub(count);
Ok(journal.entries[start..].to_vec())
}
pub fn to_markdown(&self, count: usize) -> Result<String> {
let entries = self.get_recent(count)?;
let mut md = String::new();
md.push_str(&format!(
"# Project Journal: {}\n\n",
self.get_project_name()
));
if entries.is_empty() {
md.push_str("No journal entries yet.\n");
return Ok(md);
}
for entry in entries.iter().rev() {
md.push_str(&format!("## {}\n", entry.timestamp));
match entry.entry_type {
EntryType::Commit => {
if let Some(ref msg) = entry.commit_message {
md.push_str(&format!("**Commit:** {}\n", msg));
}
if let Some(ref commit) = entry.git_commit {
md.push_str(&format!("**SHA:** `{}`\n", &commit[..7.min(commit.len())]));
}
}
EntryType::Manual => {
md.push_str("**Note**\n");
}
EntryType::Session => {
md.push_str("**Session marker**\n");
}
}
if let Some(ref branch) = entry.git_branch {
md.push_str(&format!("**Branch:** {}\n", branch));
}
if !entry.files_changed.is_empty() {
md.push_str("\n**Files changed:**\n");
for fc in &entry.files_changed {
let symbols = if fc.symbols.is_empty() {
String::new()
} else {
format!(" ({})", fc.symbols.join(", "))
};
md.push_str(&format!(
"- `{}` +{} -{}{}\n",
fc.path, fc.added, fc.removed, symbols
));
}
}
if let Some(ref note) = entry.user_note {
md.push_str(&format!("\n{}\n", note));
}
md.push_str("\n---\n\n");
}
Ok(md)
}
pub fn clear(&self) -> Result<()> {
let journal = Journal {
project_name: self.get_project_name(),
entries: vec![],
};
self.save(&journal)
}
fn get_git_branch(&self) -> Option<String> {
Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&self.project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn get_git_commit(&self) -> Option<String> {
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&self.project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn get_commit_message(&self) -> Option<String> {
Command::new("git")
.args(["log", "-1", "--format=%s"])
.current_dir(&self.project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn get_commit_changes(&self) -> Result<Vec<FileChange>> {
let output = Command::new("git")
.args(["diff", "--numstat", "HEAD~1", "HEAD"])
.current_dir(&self.project_root)
.output()?;
if !output.status.success() {
let output = Command::new("git")
.args(["diff", "--numstat", "--cached", "HEAD"])
.current_dir(&self.project_root)
.output()?;
if !output.status.success() {
return Ok(vec![]);
}
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut changes = vec![];
for line in stdout.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let added = parts[0].parse().unwrap_or(0);
let removed = parts[1].parse().unwrap_or(0);
let path = parts[2].to_string();
let symbols = self.extract_symbols_from_diff(&path);
changes.push(FileChange {
path,
added,
removed,
symbols,
});
}
}
Ok(changes)
}
fn extract_symbols_from_diff(&self, file_path: &str) -> Vec<String> {
let output = Command::new("git")
.args(["diff", "-U0", "HEAD~1", "HEAD", "--", file_path])
.current_dir(&self.project_root)
.output()
.ok();
let mut symbols = vec![];
if let Some(output) = output {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.starts_with("@@") {
if let Some(func) = extract_function_from_hunk(line) {
if !symbols.contains(&func) {
symbols.push(func);
}
}
}
}
}
}
symbols
}
fn get_project_name(&self) -> String {
self.project_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
pub fn install_hooks(&self) -> Result<()> {
let git_dir = self.project_root.join(".git");
if !git_dir.exists() {
anyhow::bail!("Not a git repository");
}
let hooks_dir = git_dir.join("hooks");
std::fs::create_dir_all(&hooks_dir)?;
let post_commit = hooks_dir.join("post-commit");
let hook_content = r#"#!/bin/sh
# Minni auto-journaling hook
if command -v minni &> /dev/null; then
minni journal record 2>/dev/null || true
fi
"#;
if post_commit.exists() {
let existing = std::fs::read_to_string(&post_commit)?;
if !existing.contains("minni journal record") {
let mut new_content = existing;
new_content.push_str(
"\n# Minni auto-journaling\nminni journal record 2>/dev/null || true\n",
);
std::fs::write(&post_commit, new_content)?;
}
} else {
std::fs::write(&post_commit, hook_content)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&post_commit)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&post_commit, perms)?;
}
Ok(())
}
pub fn uninstall_hooks(&self) -> Result<()> {
let post_commit = self
.project_root
.join(".git")
.join("hooks")
.join("post-commit");
if post_commit.exists() {
let content = std::fs::read_to_string(&post_commit)?;
let new_content: Vec<&str> = content
.lines()
.filter(|l| !l.contains("minni journal") && !l.contains("Minni auto-journaling"))
.collect();
if new_content
.iter()
.all(|l| l.trim().is_empty() || l.starts_with("#!"))
{
std::fs::remove_file(&post_commit)?;
} else {
std::fs::write(&post_commit, new_content.join("\n"))?;
}
}
Ok(())
}
}
fn extract_function_from_hunk(line: &str) -> Option<String> {
let parts: Vec<&str> = line.splitn(4, "@@").collect();
if parts.len() >= 3 {
let context = parts[2].trim();
if !context.is_empty() {
let words: Vec<&str> = context.split_whitespace().collect();
for (i, word) in words.iter().enumerate() {
if *word == "fn" || *word == "def" || *word == "function" || *word == "func" {
if let Some(name) = words.get(i + 1) {
let clean = name.split('(').next().unwrap_or(name);
return Some(clean.to_string());
}
}
}
if let Some(first) = words.first() {
if first
.chars()
.next()
.map(|c| c.is_alphabetic())
.unwrap_or(false)
{
return Some(first.to_string());
}
}
}
}
None
}