Skip to main content

remem/log/
write.rs

1use std::fs::{File, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use fs2::FileExt;
7use serde::{Deserialize, Serialize};
8
9use super::config::{
10    log_lock_path, log_policy, log_rotation_issue_path, rotated_log_path, InvalidLogEnv, LogPolicy,
11};
12
13const ROTATION_ISSUE_FRESH_SECS: i64 = 24 * 60 * 60;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub(crate) struct LogRotationIssue {
17    pub kind: String,
18    pub message: String,
19    pub path: String,
20    pub at_epoch: i64,
21}
22
23#[derive(Debug, Clone)]
24pub(crate) struct LogHealthSnapshot {
25    pub path: PathBuf,
26    pub active_bytes: u64,
27    pub total_bytes: u64,
28    pub max_bytes: u64,
29    pub max_rotated_files: usize,
30    pub lock_timeout_ms: u64,
31    pub invalid_env: Vec<InvalidLogEnv>,
32    pub issue: Option<LogRotationIssue>,
33    pub issue_is_fresh: bool,
34    pub issue_read_error: Option<String>,
35}
36
37pub(crate) fn log_health_snapshot() -> Option<LogHealthSnapshot> {
38    let policy = log_policy()?;
39    let active_bytes = file_size(&policy.path);
40    let total_bytes = active_bytes + retained_log_bytes(&policy.path, policy.max_rotated_files);
41    let (issue, issue_read_error) = match read_rotation_issue(&policy) {
42        Ok(issue) => (issue, None),
43        Err(error) => (None, Some(error)),
44    };
45    let issue_is_fresh = issue
46        .as_ref()
47        .is_some_and(|issue| issue.at_epoch >= now_epoch() - ROTATION_ISSUE_FRESH_SECS);
48    Some(LogHealthSnapshot {
49        path: policy.path,
50        active_bytes,
51        total_bytes,
52        max_bytes: policy.max_bytes,
53        max_rotated_files: policy.max_rotated_files,
54        lock_timeout_ms: policy.lock_timeout_ms,
55        invalid_env: policy.invalid_env,
56        issue,
57        issue_is_fresh,
58        issue_read_error,
59    })
60}
61
62pub(crate) fn rotate_if_needed(
63    path: &Path,
64    max_bytes: u64,
65    max_rotated_files: usize,
66) -> std::io::Result<()> {
67    cleanup_suffixes_above(path, max_rotated_files)?;
68    let size = match std::fs::metadata(path) {
69        Ok(metadata) => metadata.len(),
70        Err(_) => 0,
71    };
72    if size < max_bytes {
73        return Ok(());
74    }
75
76    if max_rotated_files == 0 {
77        match std::fs::remove_file(path) {
78            Ok(()) => return Ok(()),
79            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
80            Err(error) => return Err(error),
81        }
82    }
83
84    for index in (1..=max_rotated_files).rev() {
85        let dst = rotated_log_path(path, index);
86        if index == max_rotated_files {
87            remove_if_exists(&dst)?;
88        }
89        let src = if index == 1 {
90            path.to_path_buf()
91        } else {
92            rotated_log_path(path, index - 1)
93        };
94        if src.exists() {
95            std::fs::rename(&src, &dst)?;
96            set_private_permissions(&dst);
97        }
98    }
99    Ok(())
100}
101
102fn write_log(level: &str, component: &str, msg: &str) {
103    let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
104    let line = format!("[{}] [{}] [{}] {}", now, level, component, msg);
105    if should_mirror_to_stderr(level, component) {
106        eprintln!("{}", line);
107    }
108    let Some(policy) = log_policy() else {
109        return;
110    };
111    if let Err(error) = write_line_locked(&policy, &line) {
112        eprintln!("[remem] log write failed: {}", error);
113    }
114}
115
116fn write_line_locked(policy: &LogPolicy, line: &str) -> std::io::Result<()> {
117    match with_prepared_log(policy, |mut file| {
118        writeln!(file, "{line}")?;
119        Ok(None::<()>)
120    }) {
121        Ok(_) => Ok(()),
122        Err(error) => Err(error),
123    }
124}
125
126fn with_prepared_log<T>(
127    policy: &LogPolicy,
128    action: impl FnOnce(File) -> std::io::Result<Option<T>>,
129) -> std::io::Result<Option<T>> {
130    let prepare_started_epoch = now_epoch();
131    create_parent_dir(policy)?;
132    let lock_path = log_lock_path(&policy.path);
133    let lock_file = match private_read_write_create_options().open(&lock_path) {
134        Ok(file) => file,
135        Err(error) => {
136            record_rotation_issue(
137                policy,
138                "lock_open_failed",
139                &format!("open log lock {} failed: {}", lock_path.display(), error),
140            );
141            return append_fallback(policy, action);
142        }
143    };
144    set_private_permissions(&lock_path);
145    match try_lock_until(&lock_file, Duration::from_millis(policy.lock_timeout_ms)) {
146        Ok(true) => {}
147        Ok(false) => {
148            record_rotation_issue(
149                policy,
150                "lock_timeout",
151                &format!(
152                    "timed out after {}ms waiting for {}",
153                    policy.lock_timeout_ms,
154                    lock_path.display()
155                ),
156            );
157            return append_fallback(policy, action);
158        }
159        Err(error) => {
160            record_rotation_issue(
161                policy,
162                "lock_failed",
163                &format!("lock {} failed: {}", lock_path.display(), error),
164            );
165            return append_fallback(policy, action);
166        }
167    }
168
169    let rotate_result = rotate_if_needed(&policy.path, policy.max_bytes, policy.max_rotated_files);
170    if let Err(error) = rotate_result {
171        record_rotation_issue(
172            policy,
173            "rotate_failed",
174            &format!("rotate {} failed: {}", policy.path.display(), error),
175        );
176        return append_fallback(policy, action);
177    }
178
179    match open_private_append(&policy.path) {
180        Ok(file) => {
181            let result = action(file);
182            if result.is_ok() {
183                clear_stale_rotation_issue(policy, prepare_started_epoch);
184            }
185            result
186        }
187        Err(error) => {
188            record_rotation_issue(
189                policy,
190                "open_failed",
191                &format!("open {} failed: {}", policy.path.display(), error),
192            );
193            append_fallback(policy, action)
194        }
195    }
196}
197
198fn should_mirror_to_stderr(level: &str, component: &str) -> bool {
199    should_mirror_to_stderr_with_env(
200        level,
201        component,
202        debug_enabled(),
203        std::env::var_os("REMEM_STDERR_TO_LOG").is_some(),
204    )
205}
206
207fn should_mirror_to_stderr_with_env(
208    level: &str,
209    component: &str,
210    debug_enabled: bool,
211    stderr_to_log: bool,
212) -> bool {
213    if stderr_to_log {
214        return false;
215    }
216    // Diagnostic-only telemetry stays in the log file unless explicitly
217    // debugging; interactive commands like `remem search` keep the terminal clean.
218    if level == "INFO" && matches!(component, "migrate" | "search-perf") {
219        return debug_enabled;
220    }
221    true
222}
223
224pub fn open_log_append() -> Option<std::fs::File> {
225    let policy = log_policy()?;
226    match with_prepared_log(&policy, |file| Ok(Some(file))) {
227        Ok(file) => file,
228        Err(error) => {
229            eprintln!("[remem] open log for child stderr failed: {}", error);
230            None
231        }
232    }
233}
234
235pub fn debug_enabled() -> bool {
236    std::env::var("REMEM_DEBUG").is_ok()
237}
238
239pub fn debug(component: &str, msg: &str) {
240    if debug_enabled() {
241        write_log("DEBUG", component, msg);
242    }
243}
244
245pub fn info(component: &str, msg: &str) {
246    write_log("INFO", component, msg);
247}
248
249pub fn warn(component: &str, msg: &str) {
250    write_log("WARN", component, msg);
251}
252
253pub fn error(component: &str, msg: &str) {
254    write_log("ERROR", component, msg);
255}
256
257fn try_lock_until(file: &File, timeout: Duration) -> std::io::Result<bool> {
258    let started = Instant::now();
259    loop {
260        match file.try_lock_exclusive() {
261            Ok(()) => return Ok(true),
262            Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
263                if started.elapsed() >= timeout {
264                    return Ok(false);
265                }
266                std::thread::sleep(Duration::from_millis(5));
267            }
268            Err(error) => return Err(error),
269        }
270    }
271}
272
273fn append_fallback<T>(
274    policy: &LogPolicy,
275    action: impl FnOnce(File) -> std::io::Result<Option<T>>,
276) -> std::io::Result<Option<T>> {
277    create_parent_dir(policy)?;
278    let file = open_private_append(&policy.path)?;
279    action(file)
280}
281
282fn create_parent_dir(policy: &LogPolicy) -> std::io::Result<()> {
283    if let Some(parent) = policy.path.parent() {
284        std::fs::create_dir_all(parent)?;
285    }
286    Ok(())
287}
288
289fn open_private_append(path: &Path) -> std::io::Result<File> {
290    let file = private_append_create_options().open(path)?;
291    set_private_permissions(path);
292    Ok(file)
293}
294
295fn cleanup_suffixes_above(path: &Path, max_rotated_files: usize) -> std::io::Result<()> {
296    let Some(parent) = path.parent() else {
297        return Ok(());
298    };
299    let Some(base_name) = path.file_name().and_then(|name| name.to_str()) else {
300        return Ok(());
301    };
302    let prefix = format!("{base_name}.");
303    for entry in std::fs::read_dir(parent)? {
304        let entry = entry?;
305        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
306            continue;
307        };
308        let Some(suffix) = name.strip_prefix(&prefix) else {
309            continue;
310        };
311        let Ok(index) = suffix.parse::<usize>() else {
312            continue;
313        };
314        if index > max_rotated_files {
315            remove_if_exists(&entry.path())?;
316        }
317    }
318    Ok(())
319}
320
321fn remove_if_exists(path: &Path) -> std::io::Result<()> {
322    match std::fs::remove_file(path) {
323        Ok(()) => Ok(()),
324        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
325        Err(error) => Err(error),
326    }
327}
328
329fn retained_log_bytes(path: &Path, max_rotated_files: usize) -> u64 {
330    let configured_bytes = (1..=max_rotated_files)
331        .map(|index| file_size(&rotated_log_path(path, index)))
332        .sum::<u64>();
333    configured_bytes + suffixes_above_bytes(path, max_rotated_files)
334}
335
336fn suffixes_above_bytes(path: &Path, max_rotated_files: usize) -> u64 {
337    let Some(parent) = path.parent() else {
338        return 0;
339    };
340    let Some(base_name) = path.file_name().and_then(|name| name.to_str()) else {
341        return 0;
342    };
343    let prefix = format!("{base_name}.");
344    let Ok(entries) = std::fs::read_dir(parent) else {
345        return 0;
346    };
347    entries
348        .filter_map(Result::ok)
349        .filter_map(|entry| {
350            let name = entry.file_name().to_string_lossy().into_owned();
351            let suffix = name.strip_prefix(&prefix)?;
352            let index = suffix.parse::<usize>().ok()?;
353            (index > max_rotated_files).then(|| file_size(&entry.path()))
354        })
355        .sum()
356}
357
358fn file_size(path: &Path) -> u64 {
359    std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0)
360}
361
362fn record_rotation_issue(policy: &LogPolicy, kind: &str, message: &str) {
363    let issue = LogRotationIssue {
364        kind: kind.to_string(),
365        message: message.to_string(),
366        path: policy.path.display().to_string(),
367        at_epoch: now_epoch(),
368    };
369    let path = log_rotation_issue_path(&policy.path);
370    if let Some(parent) = path.parent() {
371        if let Err(error) = std::fs::create_dir_all(parent) {
372            report_internal_io_error("create log rotation issue directory failed", &error);
373        }
374    }
375    let tmp = path.with_file_name(format!(
376        ".{}.{}.{}.tmp",
377        path.file_name()
378            .and_then(|name| name.to_str())
379            .unwrap_or("remem-log-issue"),
380        std::process::id(),
381        chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
382    ));
383    let write_result = (|| -> std::io::Result<()> {
384        let mut file = private_write_create_new_options().open(&tmp)?;
385        let bytes = serde_json::to_vec_pretty(&issue).map_err(std::io::Error::other)?;
386        file.write_all(&bytes)?;
387        file.write_all(b"\n")?;
388        file.sync_all()?;
389        std::fs::rename(&tmp, &path)?;
390        set_private_permissions(&path);
391        Ok(())
392    })();
393    if let Err(error) = write_result {
394        remove_temp_issue_file(&tmp);
395        eprintln!("[remem] log rotation issue write failed: {}", error);
396    }
397}
398
399fn read_rotation_issue(policy: &LogPolicy) -> Result<Option<LogRotationIssue>, String> {
400    let path = log_rotation_issue_path(&policy.path);
401    let bytes = match std::fs::read(&path) {
402        Ok(bytes) => bytes,
403        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
404        Err(error) => {
405            return Err(format!("read {} failed: {}", path.display(), error));
406        }
407    };
408    serde_json::from_slice(&bytes)
409        .map(Some)
410        .map_err(|error| format!("parse {} failed: {}", path.display(), error))
411}
412
413fn clear_stale_rotation_issue(policy: &LogPolicy, prepare_started_epoch: i64) {
414    let path = log_rotation_issue_path(&policy.path);
415    if let Ok(Some(issue)) = read_rotation_issue(policy) {
416        if issue.at_epoch < prepare_started_epoch {
417            remove_rotation_issue_file(&path);
418        }
419    }
420}
421
422fn now_epoch() -> i64 {
423    chrono::Utc::now().timestamp()
424}
425
426fn private_append_create_options() -> OpenOptions {
427    let mut options = OpenOptions::new();
428    options.create(true).append(true);
429    set_create_mode(&mut options);
430    options
431}
432
433fn private_read_write_create_options() -> OpenOptions {
434    let mut options = OpenOptions::new();
435    options.create(true).read(true).write(true).truncate(false);
436    set_create_mode(&mut options);
437    options
438}
439
440fn private_write_create_new_options() -> OpenOptions {
441    let mut options = OpenOptions::new();
442    options.create_new(true).write(true);
443    set_create_mode(&mut options);
444    options
445}
446
447#[cfg(unix)]
448fn set_create_mode(options: &mut OpenOptions) {
449    use std::os::unix::fs::OpenOptionsExt;
450    options.mode(0o600);
451}
452
453#[cfg(not(unix))]
454fn set_create_mode(_options: &mut OpenOptions) {}
455
456pub(crate) fn set_private_permissions(path: &Path) {
457    #[cfg(unix)]
458    {
459        use std::os::unix::fs::PermissionsExt;
460        if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
461            report_internal_io_error("set private log permissions failed", &error);
462        }
463    }
464}
465
466fn remove_temp_issue_file(path: &Path) {
467    match std::fs::remove_file(path) {
468        Ok(()) => {}
469        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
470        Err(error) => {
471            report_internal_io_error("remove temporary log rotation issue failed", &error)
472        }
473    }
474}
475
476fn remove_rotation_issue_file(path: &Path) {
477    match std::fs::remove_file(path) {
478        Ok(()) => {}
479        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
480        Err(error) => report_internal_io_error("clear stale log rotation issue failed", &error),
481    }
482}
483
484fn report_internal_io_error(context: &str, error: &std::io::Error) {
485    eprintln!("[remem] {context}: {error}");
486}
487
488#[cfg(test)]
489mod tests {
490    #[test]
491    fn migrate_info_is_not_mirrored_to_stderr_by_default() {
492        assert!(!super::should_mirror_to_stderr_with_env(
493            "INFO", "migrate", false, false
494        ));
495        assert!(super::should_mirror_to_stderr_with_env(
496            "INFO", "install", false, false
497        ));
498        assert!(super::should_mirror_to_stderr_with_env(
499            "ERROR", "migrate", false, false
500        ));
501    }
502
503    #[test]
504    fn search_perf_info_requires_debug_like_migrate() {
505        assert!(!super::should_mirror_to_stderr_with_env(
506            "INFO",
507            "search-perf",
508            false,
509            false
510        ));
511        assert!(super::should_mirror_to_stderr_with_env(
512            "INFO",
513            "search-perf",
514            true,
515            false
516        ));
517        assert!(super::should_mirror_to_stderr_with_env(
518            "DEBUG",
519            "search-perf",
520            false,
521            false
522        ));
523    }
524
525    #[test]
526    fn migrate_info_is_mirrored_to_stderr_when_debug_enabled() {
527        assert!(super::should_mirror_to_stderr_with_env(
528            "INFO", "migrate", true, false
529        ));
530    }
531
532    #[test]
533    fn stderr_to_log_disables_stderr_mirroring() {
534        assert!(!super::should_mirror_to_stderr_with_env(
535            "INFO", "migrate", true, true
536        ));
537        assert!(!super::should_mirror_to_stderr_with_env(
538            "ERROR", "migrate", true, true
539        ));
540    }
541}