488 lines
15 KiB
Rust
488 lines
15 KiB
Rust
use serde::Serialize;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
use super::parse_github_repo_path;
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub struct PulseFile {
|
|
pub path: String,
|
|
pub status: String,
|
|
pub title: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub struct PulseCommit {
|
|
pub hash: String,
|
|
#[serde(rename = "shortHash")]
|
|
pub short_hash: String,
|
|
pub message: String,
|
|
pub date: i64,
|
|
#[serde(rename = "githubUrl")]
|
|
pub github_url: Option<String>,
|
|
pub files: Vec<PulseFile>,
|
|
pub added: usize,
|
|
pub modified: usize,
|
|
pub deleted: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone)]
|
|
pub struct LastCommitInfo {
|
|
#[serde(rename = "shortHash")]
|
|
pub short_hash: String,
|
|
#[serde(rename = "commitUrl")]
|
|
pub commit_url: Option<String>,
|
|
}
|
|
|
|
fn title_from_path(path: &str) -> String {
|
|
path.rsplit('/')
|
|
.next()
|
|
.unwrap_or(path)
|
|
.strip_suffix(".md")
|
|
.unwrap_or(path)
|
|
.replace('-', " ")
|
|
}
|
|
|
|
fn parse_file_status(code: &str) -> &str {
|
|
match code {
|
|
"A" => "added",
|
|
"M" => "modified",
|
|
"D" => "deleted",
|
|
_ => "modified",
|
|
}
|
|
}
|
|
|
|
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
|
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
|
|
pub fn get_vault_pulse(
|
|
vault_path: &str,
|
|
limit: usize,
|
|
skip: usize,
|
|
) -> Result<Vec<PulseCommit>, String> {
|
|
let vault = Path::new(vault_path);
|
|
|
|
if !vault.join(".git").exists() {
|
|
return Err("Not a git repository".to_string());
|
|
}
|
|
|
|
let limit_str = limit.to_string();
|
|
let skip_str = skip.to_string();
|
|
let output = Command::new("git")
|
|
.args([
|
|
"log",
|
|
"--name-status",
|
|
"--pretty=format:%H|%h|%s|%aI",
|
|
"--diff-filter=ADM",
|
|
"-n",
|
|
&limit_str,
|
|
"--skip",
|
|
&skip_str,
|
|
"--",
|
|
"*.md",
|
|
])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
if stderr.contains("does not have any commits yet") {
|
|
return Ok(Vec::new());
|
|
}
|
|
return Err(format!("git log failed: {}", stderr));
|
|
}
|
|
|
|
let github_base = get_github_base_url(vault_path);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
Ok(parse_pulse_output(&stdout, &github_base))
|
|
}
|
|
|
|
fn get_github_base_url(vault_path: &str) -> Option<String> {
|
|
let vault = Path::new(vault_path);
|
|
let output = Command::new("git")
|
|
.args(["remote", "get-url", "origin"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.ok()?;
|
|
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
|
|
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let repo_path = parse_github_repo_path(&url)?;
|
|
Some(format!("https://github.com/{}", repo_path))
|
|
}
|
|
|
|
fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCommit> {
|
|
let mut commits: Vec<PulseCommit> = Vec::new();
|
|
let mut current: Option<PulseCommit> = None;
|
|
|
|
for line in stdout.lines() {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if line.contains('|')
|
|
&& !line.starts_with(|c: char| {
|
|
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
|
})
|
|
{
|
|
// Commit header line: hash|short_hash|message|date
|
|
if let Some(commit) = current.take() {
|
|
commits.push(commit);
|
|
}
|
|
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
|
if parts.len() == 4 {
|
|
let hash = parts[0];
|
|
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
|
.map(|dt| dt.timestamp())
|
|
.unwrap_or(0);
|
|
let github_url = github_base
|
|
.as_ref()
|
|
.map(|base| format!("{}/commit/{}", base, hash));
|
|
|
|
current = Some(PulseCommit {
|
|
hash: hash.to_string(),
|
|
short_hash: parts[1].to_string(),
|
|
message: parts[2].to_string(),
|
|
date,
|
|
github_url,
|
|
files: Vec::new(),
|
|
added: 0,
|
|
modified: 0,
|
|
deleted: 0,
|
|
});
|
|
}
|
|
} else if let Some(ref mut commit) = current {
|
|
// File status line: A\tpath or M\tpath
|
|
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
|
|
if file_parts.len() == 2 {
|
|
let status = parse_file_status(file_parts[0].trim());
|
|
let path = file_parts[1].trim();
|
|
match status {
|
|
"added" => commit.added += 1,
|
|
"deleted" => commit.deleted += 1,
|
|
_ => commit.modified += 1,
|
|
}
|
|
commit.files.push(PulseFile {
|
|
path: path.to_string(),
|
|
status: status.to_string(),
|
|
title: title_from_path(path),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(commit) = current {
|
|
commits.push(commit);
|
|
}
|
|
|
|
commits
|
|
}
|
|
|
|
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
|
|
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
|
|
let vault = Path::new(vault_path);
|
|
|
|
let output = Command::new("git")
|
|
.args(["log", "-1", "--format=%H|%h"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
if stderr.contains("does not have any commits yet") {
|
|
return Ok(None);
|
|
}
|
|
return Err(format!("git log failed: {}", stderr));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let line = stdout.trim();
|
|
if line.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let parts: Vec<&str> = line.splitn(2, '|').collect();
|
|
if parts.len() != 2 {
|
|
return Ok(None);
|
|
}
|
|
|
|
let full_hash = parts[0];
|
|
let short_hash = parts[1].to_string();
|
|
|
|
let commit_url = get_github_commit_url(vault_path, full_hash);
|
|
|
|
Ok(Some(LastCommitInfo {
|
|
short_hash,
|
|
commit_url,
|
|
}))
|
|
}
|
|
|
|
/// Try to build a GitHub commit URL from the origin remote URL.
|
|
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
|
|
let vault = Path::new(vault_path);
|
|
let output = Command::new("git")
|
|
.args(["remote", "get-url", "origin"])
|
|
.current_dir(vault)
|
|
.output()
|
|
.ok()?;
|
|
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
|
|
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
let repo_path = parse_github_repo_path(&url)?;
|
|
Some(format!(
|
|
"https://github.com/{}/commit/{}",
|
|
repo_path, full_hash
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::git::git_commit;
|
|
use crate::git::tests::setup_git_repo;
|
|
use std::fs;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_with_commits() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
git_commit(vp, "Add note").unwrap();
|
|
|
|
fs::write(vault.join("project.md"), "# Project\n").unwrap();
|
|
git_commit(vp, "Add project").unwrap();
|
|
|
|
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
|
|
|
assert_eq!(pulse.len(), 2);
|
|
assert_eq!(pulse[0].message, "Add project");
|
|
assert_eq!(pulse[1].message, "Add note");
|
|
assert_eq!(pulse[0].files.len(), 1);
|
|
assert_eq!(pulse[0].files[0].path, "project.md");
|
|
assert_eq!(pulse[0].files[0].status, "added");
|
|
assert_eq!(pulse[0].added, 1);
|
|
assert_eq!(pulse[0].modified, 0);
|
|
assert!(!pulse[0].short_hash.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_no_git() {
|
|
let dir = TempDir::new().unwrap();
|
|
let vp = dir.path().to_str().unwrap();
|
|
|
|
let result = get_vault_pulse(vp, 30, 0);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Not a git repository"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_empty_repo() {
|
|
let dir = setup_git_repo();
|
|
let vp = dir.path().to_str().unwrap();
|
|
|
|
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
|
assert!(pulse.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_only_md_files() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
fs::write(vault.join("config.json"), "{}").unwrap();
|
|
git_commit(vp, "Add files").unwrap();
|
|
|
|
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
|
assert_eq!(pulse.len(), 1);
|
|
assert_eq!(pulse[0].files.len(), 1);
|
|
assert_eq!(pulse[0].files[0].path, "note.md");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_respects_limit() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
for i in 0..5 {
|
|
fs::write(
|
|
vault.join(format!("note{}.md", i)),
|
|
format!("# Note {}\n", i),
|
|
)
|
|
.unwrap();
|
|
git_commit(vp, &format!("Add note {}", i)).unwrap();
|
|
}
|
|
|
|
let pulse = get_vault_pulse(vp, 3, 0).unwrap();
|
|
assert_eq!(pulse.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_modified_and_deleted() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
git_commit(vp, "Add note").unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
|
|
git_commit(vp, "Update note").unwrap();
|
|
|
|
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
|
assert_eq!(pulse[0].message, "Update note");
|
|
assert_eq!(pulse[0].files[0].status, "modified");
|
|
assert_eq!(pulse[0].modified, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_github_url() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
git_commit(vp, "Add note").unwrap();
|
|
|
|
Command::new("git")
|
|
.args([
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://github.com/owner/repo.git",
|
|
])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
|
assert!(pulse[0].github_url.is_some());
|
|
let url = pulse[0].github_url.as_ref().unwrap();
|
|
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_vault_pulse_no_github_url_without_remote() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
git_commit(vp, "Add note").unwrap();
|
|
|
|
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
|
assert!(pulse[0].github_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_title_from_path() {
|
|
assert_eq!(title_from_path("note/my-project.md"), "my project");
|
|
assert_eq!(title_from_path("simple.md"), "simple");
|
|
assert_eq!(title_from_path("deep/nested/file.md"), "file");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_pulse_output_basic() {
|
|
let stdout =
|
|
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
|
|
let commits = parse_pulse_output(stdout, &None);
|
|
|
|
assert_eq!(commits.len(), 1);
|
|
assert_eq!(commits[0].message, "Add notes");
|
|
assert_eq!(commits[0].files.len(), 2);
|
|
assert_eq!(commits[0].files[0].status, "added");
|
|
assert_eq!(commits[0].files[1].status, "modified");
|
|
assert_eq!(commits[0].added, 1);
|
|
assert_eq!(commits[0].modified, 1);
|
|
assert!(commits[0].github_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_pulse_output_with_github() {
|
|
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
|
|
let base = Some("https://github.com/o/r".to_string());
|
|
let commits = parse_pulse_output(stdout, &base);
|
|
|
|
assert_eq!(
|
|
commits[0].github_url.as_deref(),
|
|
Some("https://github.com/o/r/commit/abc123")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_pulse_output_multiple_commits() {
|
|
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
|
|
let commits = parse_pulse_output(stdout, &None);
|
|
|
|
assert_eq!(commits.len(), 2);
|
|
assert_eq!(commits[0].message, "First");
|
|
assert_eq!(commits[1].message, "Second");
|
|
assert_eq!(commits[1].files.len(), 2);
|
|
assert_eq!(commits[1].deleted, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_last_commit_info_with_commit() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
git_commit(vp, "initial").unwrap();
|
|
|
|
let info = get_last_commit_info(vp).unwrap();
|
|
assert!(info.is_some());
|
|
let info = info.unwrap();
|
|
assert_eq!(info.short_hash.len(), 7);
|
|
assert!(info.commit_url.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_last_commit_info_no_commits() {
|
|
let dir = setup_git_repo();
|
|
let vp = dir.path().to_str().unwrap();
|
|
|
|
let info = get_last_commit_info(vp).unwrap();
|
|
assert!(info.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_last_commit_info_with_github_remote() {
|
|
let dir = setup_git_repo();
|
|
let vault = dir.path();
|
|
let vp = vault.to_str().unwrap();
|
|
|
|
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
|
git_commit(vp, "initial").unwrap();
|
|
|
|
Command::new("git")
|
|
.args([
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://github.com/lucaong/laputa-vault.git",
|
|
])
|
|
.current_dir(vault)
|
|
.output()
|
|
.unwrap();
|
|
|
|
let info = get_last_commit_info(vp).unwrap().unwrap();
|
|
assert!(info.commit_url.is_some());
|
|
let url = info.commit_url.unwrap();
|
|
assert!(url.starts_with("https://github.com/lucaong/laputa-vault/commit/"));
|
|
}
|
|
}
|