1use std::fs::{self, File, OpenOptions};
21use std::io::{self, Write};
22use std::path::{Path, PathBuf};
23use std::time::SystemTime;
24
25use chrono::{DateTime, NaiveDate, Utc};
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub struct SegmentRetention {
31 pub max_age_days: u32,
34 pub alarm_segment_mb: u32,
37}
38
39impl Default for SegmentRetention {
40 fn default() -> Self {
41 Self {
42 max_age_days: 14,
43 alarm_segment_mb: 256,
44 }
45 }
46}
47
48pub fn segment_name(module_id: &str, at: SystemTime) -> String {
50 format!("{module_id}.{}.log", utc_day(at))
51}
52
53pub fn segment_day(module_id: &str, file_name: &str) -> Option<NaiveDate> {
57 let rest = file_name.strip_prefix(module_id)?.strip_prefix('.')?;
58 let day = rest.strip_suffix(".log")?;
59 if day.len() != 10 {
60 return None;
61 }
62 NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()
63}
64
65pub fn prune_candidates<'a>(
68 module_id: &str,
69 today: NaiveDate,
70 retention: SegmentRetention,
71 present: impl IntoIterator<Item = &'a str>,
72) -> Vec<&'a str> {
73 let boundary = today
74 .checked_sub_days(chrono::Days::new(u64::from(retention.max_age_days)))
75 .unwrap_or(today);
76 present
77 .into_iter()
78 .filter(|name| segment_day(module_id, name).is_some_and(|day| day < boundary))
79 .collect()
80}
81
82fn utc_day(at: SystemTime) -> NaiveDate {
83 DateTime::<Utc>::from(at).date_naive()
84}
85
86pub(crate) struct SegmentDestination {
87 dir: PathBuf,
88 module_id: String,
89 retention: SegmentRetention,
90 open: Option<(NaiveDate, File)>,
91 alarmed: bool,
92}
93
94pub(crate) enum SegmentNotice {
96 Oversized { path: PathBuf, bytes: u64 },
98 Pruned { removed: usize, kept: usize },
100}
101
102impl SegmentDestination {
103 pub(crate) fn open(
104 dir: &Path,
105 module_id: &str,
106 retention: SegmentRetention,
107 now: SystemTime,
108 enforce_directory_mode: bool,
109 ) -> io::Result<(Self, Option<SegmentNotice>)> {
110 prepare_dir(dir, enforce_directory_mode)?;
111 let mut destination = Self {
112 dir: dir.to_owned(),
113 module_id: module_id.to_owned(),
114 retention,
115 open: None,
116 alarmed: false,
117 };
118 let notice = destination.roll_to(utc_day(now))?;
119 Ok((destination, notice))
120 }
121
122 pub(crate) fn write(
125 &mut self,
126 bytes: &[u8],
127 now: SystemTime,
128 ) -> io::Result<Option<SegmentNotice>> {
129 let today = utc_day(now);
130 let mut notice = None;
131 if self.open.as_ref().map(|(day, _)| *day) != Some(today) {
132 notice = self.roll_to(today)?;
133 }
134 let (_, file) = self
135 .open
136 .as_mut()
137 .ok_or_else(|| io::Error::other("log segment is not open"))?;
138 file.write_all(bytes)?;
139
140 if !self.alarmed {
141 let len = file.metadata()?.len();
142 let cap = u64::from(self.retention.alarm_segment_mb) * 1024 * 1024;
143 if len > cap {
144 self.alarmed = true;
145 notice = Some(SegmentNotice::Oversized {
146 path: self.dir.join(segment_name(&self.module_id, now)),
147 bytes: len,
148 });
149 }
150 }
151 Ok(notice)
152 }
153
154 fn roll_to(&mut self, day: NaiveDate) -> io::Result<Option<SegmentNotice>> {
160 let (removed, kept) = self.prune(day)?;
161 let path = self.dir.join(format!("{}.{day}.log", self.module_id));
162 let file = open_append(&path)?;
163 self.open = Some((day, file));
164 self.alarmed = false;
165 Ok((removed > 0).then_some(SegmentNotice::Pruned { removed, kept }))
166 }
167
168 fn prune(&self, today: NaiveDate) -> io::Result<(usize, usize)> {
169 let mut names = Vec::new();
170 for entry in fs::read_dir(&self.dir)? {
171 if let Some(name) = entry?.file_name().to_str() {
172 names.push(name.to_owned());
173 }
174 }
175 let owned: Vec<&str> = names
176 .iter()
177 .map(String::as_str)
178 .filter(|name| segment_day(&self.module_id, name).is_some())
179 .collect();
180 let doomed = prune_candidates(
181 &self.module_id,
182 today,
183 self.retention,
184 owned.iter().copied(),
185 );
186 for name in &doomed {
187 match fs::remove_file(self.dir.join(name)) {
188 Ok(()) => {}
189 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
192 Err(error) => return Err(error),
193 }
194 }
195 Ok((doomed.len(), owned.len() - doomed.len()))
196 }
197}
198
199fn prepare_dir(dir: &Path, enforce_directory_mode: bool) -> io::Result<()> {
200 let existed = dir.exists();
201 fs::create_dir_all(dir)?;
202 #[cfg(unix)]
203 if enforce_directory_mode || !existed {
204 use std::os::unix::fs::PermissionsExt;
205 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
206 }
207 #[cfg(not(unix))]
208 let _ = (enforce_directory_mode, existed);
209 Ok(())
210}
211
212fn open_append(path: &Path) -> io::Result<File> {
213 let mut options = OpenOptions::new();
214 options.create(true).append(true);
215 #[cfg(unix)]
216 {
217 use std::os::unix::fs::OpenOptionsExt;
218 options.mode(0o600);
219 }
220 let file = options.open(path)?;
221 #[cfg(unix)]
222 if file.metadata()?.file_type().is_file() {
223 use std::os::unix::fs::PermissionsExt;
224 file.set_permissions(fs::Permissions::from_mode(0o600))?;
225 }
226 Ok(file)
227}