Skip to main content

pounce_common/
journalist.rs

1//! Logging / journaling.
2//!
3//! Mirrors `Common/IpJournalist.{hpp,cpp}`. The Journalist owns
4//! multiple `Journal`s (file/stream sinks); each Journal has a
5//! per-category print level. A message of (level, category) is sent
6//! to a Journal iff `level <= journal.print_level[category]`
7//! (`J_INSUPPRESSIBLE = -1` always passes).
8//!
9//! Public API names match upstream as closely as Rust idioms allow.
10//! Iteration log diffing in Phase 7 depends on byte-identical lines;
11//! `printf!` semantics are replaced with direct write of pre-formatted
12//! strings — callers will use `std::fmt`/`format!` to assemble the
13//! line and pass the result through [`Journalist::print`].
14
15use crate::types::Index;
16use std::cell::RefCell;
17use std::fs::{File, OpenOptions};
18use std::io::{self, Write};
19use std::sync::Arc;
20use std::sync::Mutex;
21
22/// Print level. Numeric values match Ipopt's `EJournalLevel`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24#[repr(i32)]
25#[allow(non_camel_case_types)]
26pub enum JournalLevel {
27    J_INSUPPRESSIBLE = -1,
28    J_NONE = 0,
29    J_ERROR = 1,
30    J_STRONGWARNING = 2,
31    J_SUMMARY = 3,
32    J_WARNING = 4,
33    J_ITERSUMMARY = 5,
34    J_DETAILED = 6,
35    J_MOREDETAILED = 7,
36    J_VECTOR = 8,
37    J_MOREVECTOR = 9,
38    J_MATRIX = 10,
39    J_MOREMATRIX = 11,
40    J_ALL = 12,
41}
42
43impl JournalLevel {
44    pub const J_LAST_LEVEL: i32 = 13;
45}
46
47/// Category. Numeric values match `EJournalCategory`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49#[repr(usize)]
50#[allow(non_camel_case_types)]
51pub enum JournalCategory {
52    J_DBG = 0,
53    J_STATISTICS = 1,
54    J_MAIN = 2,
55    J_INITIALIZATION = 3,
56    J_BARRIER_UPDATE = 4,
57    J_SOLVE_PD_SYSTEM = 5,
58    J_FRAC_TO_BOUND = 6,
59    J_LINEAR_ALGEBRA = 7,
60    J_LINE_SEARCH = 8,
61    J_HESSIAN_APPROXIMATION = 9,
62    J_SOLUTION = 10,
63    J_DOCUMENTATION = 11,
64    J_NLP = 12,
65    J_TIMING_STATISTICS = 13,
66    J_USER_APPLICATION = 14,
67    J_USER1 = 15,
68    J_USER2 = 16,
69    J_USER3 = 17,
70    J_USER4 = 18,
71    J_USER5 = 19,
72    J_USER6 = 20,
73    J_USER7 = 21,
74    J_USER8 = 22,
75    J_USER9 = 23,
76    J_USER10 = 24,
77    J_USER11 = 25,
78    J_USER12 = 26,
79    J_USER13 = 27,
80    J_USER14 = 28,
81    J_USER15 = 29,
82    J_USER16 = 30,
83    J_USER17 = 31,
84}
85
86impl JournalCategory {
87    pub const J_LAST_CATEGORY: usize = 32;
88}
89
90/// Trait for a single output sink. Implementors handle one of
91/// stdout/stderr/a file/a string buffer. Mirrors `Ipopt::Journal`.
92pub trait Journal: Send + Sync {
93    fn name(&self) -> &str;
94
95    /// Acceptance check — returns true iff the journal would emit a
96    /// message at `(level, category)`.
97    fn is_accepted(&self, category: JournalCategory, level: JournalLevel) -> bool;
98
99    fn set_print_level(&self, category: JournalCategory, level: JournalLevel);
100
101    fn set_all_print_levels(&self, level: JournalLevel);
102
103    /// Emit a pre-formatted string (callers do their own formatting).
104    fn print(&self, category: JournalCategory, level: JournalLevel, s: &str);
105
106    fn flush(&self);
107}
108
109/// Per-category level table shared by every concrete Journal impl.
110struct LevelTable {
111    levels: [i32; JournalCategory::J_LAST_CATEGORY],
112}
113
114impl LevelTable {
115    fn new(default_level: JournalLevel) -> Self {
116        Self {
117            levels: [default_level as i32; JournalCategory::J_LAST_CATEGORY],
118        }
119    }
120
121    fn is_accepted(&self, category: JournalCategory, level: JournalLevel) -> bool {
122        // J_INSUPPRESSIBLE always emits (matches upstream IsAccepted).
123        if (level as i32) == JournalLevel::J_INSUPPRESSIBLE as i32 {
124            return true;
125        }
126        (level as i32) <= self.levels[category as usize]
127    }
128
129    fn set_level(&mut self, category: JournalCategory, level: JournalLevel) {
130        self.levels[category as usize] = level as i32;
131    }
132
133    fn set_all(&mut self, level: JournalLevel) {
134        for v in &mut self.levels {
135            *v = level as i32;
136        }
137    }
138}
139
140enum FileSink {
141    Stdout,
142    Stderr,
143    File(File),
144}
145
146impl FileSink {
147    fn write(&mut self, s: &str) -> io::Result<()> {
148        match self {
149            FileSink::Stdout => io::stdout().write_all(s.as_bytes()),
150            FileSink::Stderr => io::stderr().write_all(s.as_bytes()),
151            FileSink::File(f) => f.write_all(s.as_bytes()),
152        }
153    }
154    fn flush(&mut self) -> io::Result<()> {
155        match self {
156            FileSink::Stdout => io::stdout().flush(),
157            FileSink::Stderr => io::stderr().flush(),
158            FileSink::File(f) => f.flush(),
159        }
160    }
161}
162
163/// Mirrors `FileJournal` — writes to stdout/stderr/disk.
164pub struct FileJournal {
165    name: String,
166    levels: Mutex<LevelTable>,
167    sink: Mutex<FileSink>,
168}
169
170impl FileJournal {
171    pub fn new(name: impl Into<String>, default_level: JournalLevel) -> Self {
172        Self {
173            name: name.into(),
174            levels: Mutex::new(LevelTable::new(default_level)),
175            sink: Mutex::new(FileSink::Stdout),
176        }
177    }
178
179    /// Mirrors `FileJournal::Open`. `"stdout"`/`"stderr"` are
180    /// recognised as special filenames. Returns false if the file
181    /// could not be opened.
182    pub fn open(&self, fname: &str, append: bool) -> bool {
183        let new_sink = match fname {
184            "stdout" => FileSink::Stdout,
185            "stderr" => FileSink::Stderr,
186            other => {
187                let mut opts = OpenOptions::new();
188                opts.write(true).create(true);
189                if append {
190                    opts.append(true);
191                } else {
192                    opts.truncate(true);
193                }
194                match opts.open(other) {
195                    Ok(f) => FileSink::File(f),
196                    Err(_) => return false,
197                }
198            }
199        };
200        match self.sink.lock() {
201            Ok(mut s) => {
202                *s = new_sink;
203                true
204            }
205            _ => false,
206        }
207    }
208}
209
210impl Journal for FileJournal {
211    fn name(&self) -> &str {
212        &self.name
213    }
214
215    fn is_accepted(&self, category: JournalCategory, level: JournalLevel) -> bool {
216        self.levels
217            .lock()
218            .map(|t| t.is_accepted(category, level))
219            .unwrap_or(false)
220    }
221
222    fn set_print_level(&self, category: JournalCategory, level: JournalLevel) {
223        if let Ok(mut t) = self.levels.lock() {
224            t.set_level(category, level);
225        }
226    }
227
228    fn set_all_print_levels(&self, level: JournalLevel) {
229        if let Ok(mut t) = self.levels.lock() {
230            t.set_all(level);
231        }
232    }
233
234    fn print(&self, category: JournalCategory, level: JournalLevel, s: &str) {
235        if !self.is_accepted(category, level) {
236            return;
237        }
238        if let Ok(mut sink) = self.sink.lock() {
239            let _ = sink.write(s);
240        }
241    }
242
243    fn flush(&self) {
244        if let Ok(mut sink) = self.sink.lock() {
245            let _ = sink.flush();
246        }
247    }
248}
249
250/// In-memory sink used by tests and the option-printing path.
251pub struct StringJournal {
252    name: String,
253    levels: Mutex<LevelTable>,
254    buffer: Mutex<String>,
255}
256
257impl StringJournal {
258    pub fn new(name: impl Into<String>, default_level: JournalLevel) -> Self {
259        Self {
260            name: name.into(),
261            levels: Mutex::new(LevelTable::new(default_level)),
262            buffer: Mutex::new(String::new()),
263        }
264    }
265
266    pub fn contents(&self) -> String {
267        self.buffer.lock().map(|b| b.clone()).unwrap_or_default()
268    }
269
270    pub fn take(&self) -> String {
271        self.buffer
272            .lock()
273            .map(|mut b| std::mem::take(&mut *b))
274            .unwrap_or_default()
275    }
276}
277
278impl Journal for StringJournal {
279    fn name(&self) -> &str {
280        &self.name
281    }
282
283    fn is_accepted(&self, category: JournalCategory, level: JournalLevel) -> bool {
284        self.levels
285            .lock()
286            .map(|t| t.is_accepted(category, level))
287            .unwrap_or(false)
288    }
289
290    fn set_print_level(&self, category: JournalCategory, level: JournalLevel) {
291        if let Ok(mut t) = self.levels.lock() {
292            t.set_level(category, level);
293        }
294    }
295
296    fn set_all_print_levels(&self, level: JournalLevel) {
297        if let Ok(mut t) = self.levels.lock() {
298            t.set_all(level);
299        }
300    }
301
302    fn print(&self, category: JournalCategory, level: JournalLevel, s: &str) {
303        if !self.is_accepted(category, level) {
304            return;
305        }
306        if let Ok(mut buf) = self.buffer.lock() {
307            buf.push_str(s);
308        }
309    }
310
311    fn flush(&self) {}
312}
313
314/// The Journalist owns a list of journals and dispatches messages.
315/// Mirrors `Ipopt::Journalist`.
316#[derive(Default)]
317pub struct Journalist {
318    journals: RefCell<Vec<Arc<dyn Journal>>>,
319}
320
321impl Journalist {
322    pub fn new() -> Self {
323        Self::default()
324    }
325
326    pub fn add_journal(&self, j: Arc<dyn Journal>) -> bool {
327        match self.journals.try_borrow_mut() {
328            Ok(journals) => {
329                for existing in journals.iter() {
330                    if existing.name() == j.name() {
331                        return false;
332                    }
333                }
334                drop(journals);
335                self.journals.borrow_mut().push(j);
336                true
337            }
338            _ => false,
339        }
340    }
341
342    /// Convenience: add a `FileJournal` writing to `fname`.
343    pub fn add_file_journal(
344        &self,
345        location_name: &str,
346        fname: &str,
347        default_level: JournalLevel,
348        append: bool,
349    ) -> Option<Arc<FileJournal>> {
350        let j = Arc::new(FileJournal::new(location_name, default_level));
351        if !j.open(fname, append) {
352            return None;
353        }
354        let dyn_j: Arc<dyn Journal> = j.clone();
355        if !self.add_journal(dyn_j) {
356            return None;
357        }
358        Some(j)
359    }
360
361    pub fn get_journal(&self, location_name: &str) -> Option<Arc<dyn Journal>> {
362        self.journals
363            .borrow()
364            .iter()
365            .find(|j| j.name() == location_name)
366            .cloned()
367    }
368
369    pub fn delete_all_journals(&self) {
370        self.journals.borrow_mut().clear();
371    }
372
373    /// Emit a pre-formatted string to every accepting journal.
374    /// Equivalent to `Journalist::Printf` after the C-style format
375    /// expansion has been done in the caller.
376    pub fn print(&self, level: JournalLevel, category: JournalCategory, s: &str) {
377        for j in self.journals.borrow().iter() {
378            j.print(category, level, s);
379        }
380    }
381
382    /// Mirrors `PrintfIndented` — prepends `2 * indent_level` spaces.
383    pub fn print_indented(
384        &self,
385        level: JournalLevel,
386        category: JournalCategory,
387        indent_level: Index,
388        s: &str,
389    ) {
390        let pad = " ".repeat((indent_level.max(0) as usize) * 2);
391        // Indent every line so multi-line payloads match upstream.
392        let mut out = String::with_capacity(s.len() + pad.len());
393        let mut first = true;
394        for line in s.split_inclusive('\n') {
395            if !first || !line.is_empty() {
396                out.push_str(&pad);
397            }
398            out.push_str(line);
399            first = false;
400        }
401        self.print(level, category, &out);
402    }
403
404    /// Mirrors `ProduceOutput` — true iff at least one journal accepts.
405    pub fn produce_output(&self, level: JournalLevel, category: JournalCategory) -> bool {
406        self.journals
407            .borrow()
408            .iter()
409            .any(|j| j.is_accepted(category, level))
410    }
411
412    pub fn flush_buffer(&self) {
413        for j in self.journals.borrow().iter() {
414            j.flush();
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn level_filtering() {
425        let jnlst = Journalist::new();
426        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_SUMMARY));
427        jnlst.add_journal(j.clone());
428        jnlst.print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, "err\n");
429        jnlst.print(
430            JournalLevel::J_DETAILED,
431            JournalCategory::J_MAIN,
432            "detail\n",
433        );
434        let s = j.contents();
435        assert!(s.contains("err"));
436        assert!(!s.contains("detail"));
437    }
438
439    #[test]
440    fn insuppressible_always_emits() {
441        let jnlst = Journalist::new();
442        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_NONE));
443        jnlst.add_journal(j.clone());
444        jnlst.print(
445            JournalLevel::J_INSUPPRESSIBLE,
446            JournalCategory::J_MAIN,
447            "x\n",
448        );
449        assert_eq!(j.contents(), "x\n");
450    }
451
452    #[test]
453    fn produce_output_reflects_journals() {
454        let jnlst = Journalist::new();
455        assert!(!jnlst.produce_output(JournalLevel::J_ERROR, JournalCategory::J_MAIN));
456        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_SUMMARY));
457        jnlst.add_journal(j);
458        assert!(jnlst.produce_output(JournalLevel::J_ERROR, JournalCategory::J_MAIN));
459        assert!(!jnlst.produce_output(JournalLevel::J_DETAILED, JournalCategory::J_MAIN));
460    }
461
462    #[test]
463    fn indent_prepends_two_spaces_per_level() {
464        let jnlst = Journalist::new();
465        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_ALL));
466        jnlst.add_journal(j.clone());
467        jnlst.print_indented(JournalLevel::J_SUMMARY, JournalCategory::J_MAIN, 2, "x\n");
468        assert_eq!(j.contents(), "    x\n");
469    }
470}