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        if let Ok(mut s) = self.sink.lock() {
201            *s = new_sink;
202            true
203        } else {
204            false
205        }
206    }
207}
208
209impl Journal for FileJournal {
210    fn name(&self) -> &str {
211        &self.name
212    }
213
214    fn is_accepted(&self, category: JournalCategory, level: JournalLevel) -> bool {
215        self.levels
216            .lock()
217            .map(|t| t.is_accepted(category, level))
218            .unwrap_or(false)
219    }
220
221    fn set_print_level(&self, category: JournalCategory, level: JournalLevel) {
222        if let Ok(mut t) = self.levels.lock() {
223            t.set_level(category, level);
224        }
225    }
226
227    fn set_all_print_levels(&self, level: JournalLevel) {
228        if let Ok(mut t) = self.levels.lock() {
229            t.set_all(level);
230        }
231    }
232
233    fn print(&self, category: JournalCategory, level: JournalLevel, s: &str) {
234        if !self.is_accepted(category, level) {
235            return;
236        }
237        if let Ok(mut sink) = self.sink.lock() {
238            let _ = sink.write(s);
239        }
240    }
241
242    fn flush(&self) {
243        if let Ok(mut sink) = self.sink.lock() {
244            let _ = sink.flush();
245        }
246    }
247}
248
249/// In-memory sink used by tests and the option-printing path.
250pub struct StringJournal {
251    name: String,
252    levels: Mutex<LevelTable>,
253    buffer: Mutex<String>,
254}
255
256impl StringJournal {
257    pub fn new(name: impl Into<String>, default_level: JournalLevel) -> Self {
258        Self {
259            name: name.into(),
260            levels: Mutex::new(LevelTable::new(default_level)),
261            buffer: Mutex::new(String::new()),
262        }
263    }
264
265    pub fn contents(&self) -> String {
266        self.buffer.lock().map(|b| b.clone()).unwrap_or_default()
267    }
268
269    pub fn take(&self) -> String {
270        self.buffer
271            .lock()
272            .map(|mut b| std::mem::take(&mut *b))
273            .unwrap_or_default()
274    }
275}
276
277impl Journal for StringJournal {
278    fn name(&self) -> &str {
279        &self.name
280    }
281
282    fn is_accepted(&self, category: JournalCategory, level: JournalLevel) -> bool {
283        self.levels
284            .lock()
285            .map(|t| t.is_accepted(category, level))
286            .unwrap_or(false)
287    }
288
289    fn set_print_level(&self, category: JournalCategory, level: JournalLevel) {
290        if let Ok(mut t) = self.levels.lock() {
291            t.set_level(category, level);
292        }
293    }
294
295    fn set_all_print_levels(&self, level: JournalLevel) {
296        if let Ok(mut t) = self.levels.lock() {
297            t.set_all(level);
298        }
299    }
300
301    fn print(&self, category: JournalCategory, level: JournalLevel, s: &str) {
302        if !self.is_accepted(category, level) {
303            return;
304        }
305        if let Ok(mut buf) = self.buffer.lock() {
306            buf.push_str(s);
307        }
308    }
309
310    fn flush(&self) {}
311}
312
313/// The Journalist owns a list of journals and dispatches messages.
314/// Mirrors `Ipopt::Journalist`.
315#[derive(Default)]
316pub struct Journalist {
317    journals: RefCell<Vec<Arc<dyn Journal>>>,
318}
319
320impl Journalist {
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    pub fn add_journal(&self, j: Arc<dyn Journal>) -> bool {
326        if let Ok(journals) = self.journals.try_borrow_mut() {
327            for existing in journals.iter() {
328                if existing.name() == j.name() {
329                    return false;
330                }
331            }
332            drop(journals);
333            self.journals.borrow_mut().push(j);
334            true
335        } else {
336            false
337        }
338    }
339
340    /// Convenience: add a `FileJournal` writing to `fname`.
341    pub fn add_file_journal(
342        &self,
343        location_name: &str,
344        fname: &str,
345        default_level: JournalLevel,
346        append: bool,
347    ) -> Option<Arc<FileJournal>> {
348        let j = Arc::new(FileJournal::new(location_name, default_level));
349        if !j.open(fname, append) {
350            return None;
351        }
352        let dyn_j: Arc<dyn Journal> = j.clone();
353        if !self.add_journal(dyn_j) {
354            return None;
355        }
356        Some(j)
357    }
358
359    pub fn get_journal(&self, location_name: &str) -> Option<Arc<dyn Journal>> {
360        self.journals
361            .borrow()
362            .iter()
363            .find(|j| j.name() == location_name)
364            .cloned()
365    }
366
367    pub fn delete_all_journals(&self) {
368        self.journals.borrow_mut().clear();
369    }
370
371    /// Emit a pre-formatted string to every accepting journal.
372    /// Equivalent to `Journalist::Printf` after the C-style format
373    /// expansion has been done in the caller.
374    pub fn print(&self, level: JournalLevel, category: JournalCategory, s: &str) {
375        for j in self.journals.borrow().iter() {
376            j.print(category, level, s);
377        }
378    }
379
380    /// Mirrors `PrintfIndented` — prepends `2 * indent_level` spaces.
381    pub fn print_indented(
382        &self,
383        level: JournalLevel,
384        category: JournalCategory,
385        indent_level: Index,
386        s: &str,
387    ) {
388        let pad = " ".repeat((indent_level.max(0) as usize) * 2);
389        // Indent every line so multi-line payloads match upstream.
390        let mut out = String::with_capacity(s.len() + pad.len());
391        let mut first = true;
392        for line in s.split_inclusive('\n') {
393            if !first || !line.is_empty() {
394                out.push_str(&pad);
395            }
396            out.push_str(line);
397            first = false;
398        }
399        self.print(level, category, &out);
400    }
401
402    /// Mirrors `ProduceOutput` — true iff at least one journal accepts.
403    pub fn produce_output(&self, level: JournalLevel, category: JournalCategory) -> bool {
404        self.journals
405            .borrow()
406            .iter()
407            .any(|j| j.is_accepted(category, level))
408    }
409
410    pub fn flush_buffer(&self) {
411        for j in self.journals.borrow().iter() {
412            j.flush();
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn level_filtering() {
423        let jnlst = Journalist::new();
424        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_SUMMARY));
425        jnlst.add_journal(j.clone());
426        jnlst.print(JournalLevel::J_ERROR, JournalCategory::J_MAIN, "err\n");
427        jnlst.print(
428            JournalLevel::J_DETAILED,
429            JournalCategory::J_MAIN,
430            "detail\n",
431        );
432        let s = j.contents();
433        assert!(s.contains("err"));
434        assert!(!s.contains("detail"));
435    }
436
437    #[test]
438    fn insuppressible_always_emits() {
439        let jnlst = Journalist::new();
440        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_NONE));
441        jnlst.add_journal(j.clone());
442        jnlst.print(
443            JournalLevel::J_INSUPPRESSIBLE,
444            JournalCategory::J_MAIN,
445            "x\n",
446        );
447        assert_eq!(j.contents(), "x\n");
448    }
449
450    #[test]
451    fn produce_output_reflects_journals() {
452        let jnlst = Journalist::new();
453        assert!(!jnlst.produce_output(JournalLevel::J_ERROR, JournalCategory::J_MAIN));
454        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_SUMMARY));
455        jnlst.add_journal(j);
456        assert!(jnlst.produce_output(JournalLevel::J_ERROR, JournalCategory::J_MAIN));
457        assert!(!jnlst.produce_output(JournalLevel::J_DETAILED, JournalCategory::J_MAIN));
458    }
459
460    #[test]
461    fn indent_prepends_two_spaces_per_level() {
462        let jnlst = Journalist::new();
463        let j = Arc::new(StringJournal::new("buf", JournalLevel::J_ALL));
464        jnlst.add_journal(j.clone());
465        jnlst.print_indented(JournalLevel::J_SUMMARY, JournalCategory::J_MAIN, 2, "x\n");
466        assert_eq!(j.contents(), "    x\n");
467    }
468}