Skip to main content

git_paw/
logging.rs

1//! Session logging via tmux pipe-pane.
2//!
3//! Captures per-pane terminal output to `.git-paw/logs/<session>/<branch>.log`.
4//! Provides log directory management and session/log enumeration.
5
6use std::path::{Path, PathBuf};
7
8use crate::error::PawError;
9
10/// A single log file entry, pairing a branch name with its log path.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LogEntry {
13    /// The original branch name (e.g. `feat/add-auth`), recovered from the sanitized filename.
14    pub branch: String,
15    /// Absolute path to the log file.
16    pub path: PathBuf,
17}
18
19/// Replace `/` with `--` so the branch name is safe for use as a filename.
20pub fn sanitize_branch_for_filename(branch: &str) -> String {
21    branch.replace('/', "--")
22}
23
24/// Reverse [`sanitize_branch_for_filename`]: replace `--` with `/` and strip the `.log` suffix.
25pub fn unsanitize_branch_from_filename(filename: &str) -> String {
26    let stem = filename.strip_suffix(".log").unwrap_or(filename);
27    stem.replace("--", "/")
28}
29
30/// Build the full log file path for a branch within a session.
31///
32/// Returns `<repo_root>/.git-paw/logs/<session_id>/<sanitized-branch>.log`.
33pub fn log_file_path(repo_root: &Path, session_id: &str, branch: &str) -> PathBuf {
34    repo_root
35        .join(".git-paw")
36        .join("logs")
37        .join(session_id)
38        .join(format!("{}.log", sanitize_branch_for_filename(branch)))
39}
40
41/// Create the session log directory, returning its path.
42///
43/// Creates `.git-paw/logs/<session_id>/` under `repo_root`. Idempotent — succeeds
44/// if the directory already exists.
45pub fn ensure_log_dir(repo_root: &Path, session_id: &str) -> Result<PathBuf, PawError> {
46    let dir = repo_root.join(".git-paw").join("logs").join(session_id);
47    std::fs::create_dir_all(&dir).map_err(|e| {
48        PawError::SessionError(format!(
49            "failed to create log directory {}: {e}",
50            dir.display()
51        ))
52    })?;
53    Ok(dir)
54}
55
56/// Returns the logs directory path (`.git-paw/logs/`) under the given repo root.
57pub fn logs_dir(repo_root: &Path) -> PathBuf {
58    repo_root.join(".git-paw").join("logs")
59}
60
61/// List all session log directories under `.git-paw/logs/`.
62///
63/// Returns an empty list if the logs directory does not exist.
64pub fn list_log_sessions(repo_root: &Path) -> Result<Vec<String>, PawError> {
65    let logs_dir = repo_root.join(".git-paw").join("logs");
66    if !logs_dir.exists() {
67        return Ok(Vec::new());
68    }
69
70    let mut sessions = Vec::new();
71    let entries = std::fs::read_dir(&logs_dir)
72        .map_err(|e| PawError::SessionError(format!("failed to read logs directory: {e}")))?;
73
74    for entry in entries {
75        let entry = entry
76            .map_err(|e| PawError::SessionError(format!("failed to read directory entry: {e}")))?;
77        if entry.path().is_dir()
78            && let Some(name) = entry.file_name().to_str()
79        {
80            sessions.push(name.to_owned());
81        }
82    }
83
84    sessions.sort();
85    Ok(sessions)
86}
87
88/// List log files within a session directory, returning [`LogEntry`] items.
89///
90/// Returns `PawError::SessionError` if the session directory does not exist.
91pub fn list_logs_for_session(repo_root: &Path, session: &str) -> Result<Vec<LogEntry>, PawError> {
92    let session_dir = repo_root.join(".git-paw").join("logs").join(session);
93    if !session_dir.exists() {
94        return Err(PawError::SessionError(format!(
95            "session directory not found: {session}"
96        )));
97    }
98
99    let mut entries = Vec::new();
100    let dir_entries = std::fs::read_dir(&session_dir)
101        .map_err(|e| PawError::SessionError(format!("failed to read session directory: {e}")))?;
102
103    for entry in dir_entries {
104        let entry = entry
105            .map_err(|e| PawError::SessionError(format!("failed to read directory entry: {e}")))?;
106        let path = entry.path();
107        if path.is_file()
108            && let Some(filename) = path.file_name().and_then(|f| f.to_str())
109            && Path::new(filename)
110                .extension()
111                .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
112        {
113            entries.push(LogEntry {
114                branch: unsanitize_branch_from_filename(filename),
115                path,
116            });
117        }
118    }
119
120    entries.sort_by(|a, b| a.branch.cmp(&b.branch));
121    Ok(entries)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use tempfile::TempDir;
128
129    // -- sanitize / unsanitize ------------------------------------------------
130
131    #[test]
132    fn sanitize_simple_name() {
133        assert_eq!(sanitize_branch_for_filename("add-auth"), "add-auth");
134    }
135
136    #[test]
137    fn sanitize_single_slash() {
138        assert_eq!(
139            sanitize_branch_for_filename("feat/add-auth"),
140            "feat--add-auth"
141        );
142    }
143
144    #[test]
145    fn sanitize_multiple_slashes() {
146        assert_eq!(
147            sanitize_branch_for_filename("feat/auth/jwt"),
148            "feat--auth--jwt"
149        );
150    }
151
152    #[test]
153    fn unsanitize_simple_name() {
154        assert_eq!(unsanitize_branch_from_filename("add-auth.log"), "add-auth");
155    }
156
157    #[test]
158    fn unsanitize_single_slash() {
159        assert_eq!(
160            unsanitize_branch_from_filename("feat--add-auth.log"),
161            "feat/add-auth"
162        );
163    }
164
165    #[test]
166    fn unsanitize_multiple_slashes() {
167        assert_eq!(
168            unsanitize_branch_from_filename("feat--auth--jwt.log"),
169            "feat/auth/jwt"
170        );
171    }
172
173    // -- log_file_path --------------------------------------------------------
174
175    #[test]
176    fn log_file_path_produces_correct_structure() {
177        let path = log_file_path(Path::new("/repo"), "paw-myproject", "feat/add-auth");
178        assert_eq!(
179            path,
180            PathBuf::from("/repo/.git-paw/logs/paw-myproject/feat--add-auth.log")
181        );
182    }
183
184    // -- ensure_log_dir -------------------------------------------------------
185
186    #[test]
187    fn ensure_log_dir_creates_directory() {
188        let tmp = TempDir::new().unwrap();
189        let dir = ensure_log_dir(tmp.path(), "paw-test").unwrap();
190        assert!(dir.is_dir());
191        assert_eq!(dir, tmp.path().join(".git-paw/logs/paw-test"));
192    }
193
194    #[test]
195    fn ensure_log_dir_is_idempotent() {
196        let tmp = TempDir::new().unwrap();
197        let first = ensure_log_dir(tmp.path(), "paw-test").unwrap();
198        let second = ensure_log_dir(tmp.path(), "paw-test").unwrap();
199        assert_eq!(first, second);
200        assert!(second.is_dir());
201    }
202
203    // -- list_log_sessions ----------------------------------------------------
204
205    #[test]
206    fn list_log_sessions_returns_sessions() {
207        let tmp = TempDir::new().unwrap();
208        std::fs::create_dir_all(tmp.path().join(".git-paw/logs/paw-myproject")).unwrap();
209        std::fs::create_dir_all(tmp.path().join(".git-paw/logs/paw-other")).unwrap();
210
211        let sessions = list_log_sessions(tmp.path()).unwrap();
212        assert_eq!(sessions, vec!["paw-myproject", "paw-other"]);
213    }
214
215    #[test]
216    fn list_log_sessions_returns_empty_when_no_sessions() {
217        let tmp = TempDir::new().unwrap();
218        std::fs::create_dir_all(tmp.path().join(".git-paw/logs")).unwrap();
219
220        let sessions = list_log_sessions(tmp.path()).unwrap();
221        assert!(sessions.is_empty());
222    }
223
224    #[test]
225    fn list_log_sessions_returns_empty_when_no_logs_dir() {
226        let tmp = TempDir::new().unwrap();
227        let sessions = list_log_sessions(tmp.path()).unwrap();
228        assert!(sessions.is_empty());
229    }
230
231    // -- list_logs_for_session ------------------------------------------------
232
233    #[test]
234    fn list_logs_for_session_returns_entries() {
235        let tmp = TempDir::new().unwrap();
236        let session_dir = tmp.path().join(".git-paw/logs/paw-test");
237        std::fs::create_dir_all(&session_dir).unwrap();
238        std::fs::write(session_dir.join("main.log"), "").unwrap();
239        std::fs::write(session_dir.join("feat--auth.log"), "").unwrap();
240        std::fs::write(session_dir.join("feat--api--v2.log"), "").unwrap();
241
242        let entries = list_logs_for_session(tmp.path(), "paw-test").unwrap();
243        assert_eq!(entries.len(), 3);
244        assert_eq!(entries[0].branch, "feat/api/v2");
245        assert_eq!(entries[1].branch, "feat/auth");
246        assert_eq!(entries[2].branch, "main");
247    }
248
249    #[test]
250    fn list_logs_for_session_returns_empty_when_no_logs() {
251        let tmp = TempDir::new().unwrap();
252        std::fs::create_dir_all(tmp.path().join(".git-paw/logs/paw-test")).unwrap();
253
254        let entries = list_logs_for_session(tmp.path(), "paw-test").unwrap();
255        assert!(entries.is_empty());
256    }
257
258    #[test]
259    fn list_logs_for_session_errors_when_session_missing() {
260        let tmp = TempDir::new().unwrap();
261        let result = list_logs_for_session(tmp.path(), "paw-nonexistent");
262        assert!(result.is_err());
263        let msg = result.unwrap_err().to_string();
264        assert!(msg.contains("paw-nonexistent"));
265    }
266
267    // -- LogEntry branch derivation -------------------------------------------
268
269    #[test]
270    fn log_entry_branch_from_sanitized_filename() {
271        let entry = LogEntry {
272            branch: unsanitize_branch_from_filename("feat--add-auth.log"),
273            path: PathBuf::from("/repo/.git-paw/logs/paw-test/feat--add-auth.log"),
274        };
275        assert_eq!(entry.branch, "feat/add-auth");
276    }
277}