1use std::path::{Path, PathBuf};
7
8use crate::error::PawError;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LogEntry {
13 pub branch: String,
15 pub path: PathBuf,
17}
18
19pub fn sanitize_branch_for_filename(branch: &str) -> String {
21 branch.replace('/', "--")
22}
23
24pub fn unsanitize_branch_from_filename(filename: &str) -> String {
26 let stem = filename.strip_suffix(".log").unwrap_or(filename);
27 stem.replace("--", "/")
28}
29
30pub 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
41pub 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
56pub fn logs_dir(repo_root: &Path) -> PathBuf {
58 repo_root.join(".git-paw").join("logs")
59}
60
61pub 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
88pub 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 #[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 #[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 #[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 #[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 #[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 #[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}