mprocs 0.9.1

TUI for running multiple processes
Documentation
use std::{
  path::PathBuf,
  time::{SystemTime, UNIX_EPOCH},
};

use anyhow::Result;
use serde_yaml::Value;

use crate::yaml_val::Val;

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LogMode {
  #[default]
  Append,
  Truncate,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LogConfig {
  pub enabled: Option<bool>,
  pub dir: Option<PathBuf>,
  pub file: Option<PathBuf>,
  pub mode: Option<LogMode>,
}

impl LogConfig {
  pub fn disabled() -> Self {
    Self {
      enabled: Some(false),
      dir: None,
      file: None,
      mode: None,
    }
  }

  pub fn with_dir(dir: PathBuf) -> Self {
    Self {
      enabled: Some(true),
      dir: Some(dir),
      file: None,
      mode: None,
    }
  }

  pub fn enabled(&self) -> bool {
    self.enabled.unwrap_or(true)
  }

  pub fn mode(&self) -> LogMode {
    self.mode.unwrap_or(LogMode::Append)
  }

  pub fn merged(&self, child: &LogConfig) -> LogConfig {
    LogConfig {
      enabled: child.enabled.or(self.enabled),
      dir: child.dir.clone().or_else(|| self.dir.clone()),
      file: child.file.clone().or_else(|| self.file.clone()),
      mode: child.mode.or(self.mode),
    }
  }

  pub fn file_path(
    &self,
    proc_name: &str,
    proc_id: usize,
    pid: u32,
  ) -> Option<PathBuf> {
    if !self.enabled() {
      return None;
    }

    let dir = self.dir.as_ref().map(|dir| {
      PathBuf::from(expand_template(
        &dir.to_string_lossy(),
        proc_name,
        proc_id,
        pid,
      ))
    });
    let file = self.file.as_ref().map(|file| {
      PathBuf::from(expand_template(
        &file.to_string_lossy(),
        proc_name,
        proc_id,
        pid,
      ))
    });

    match (dir, file) {
      (None, None) => None,
      (Some(dir), None) => Some(dir.join(default_log_filename(proc_name))),
      (None, Some(file)) => Some(file),
      (Some(dir), Some(file)) if file.is_relative() => Some(dir.join(file)),
      (Some(_dir), Some(file)) => Some(file),
    }
  }
}

pub fn default_log_filename(name: &str) -> String {
  let mut out = String::new();
  for ch in name.chars() {
    let is_safe = ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.');
    if is_safe {
      out.push(ch);
    } else {
      out.push('_');
    }
  }

  let trimmed = out.trim_matches(|c| c == '.' || c == ' ').to_string();
  if trimmed.is_empty() {
    "process.log".to_string()
  } else {
    format!("{}.log", trimmed)
  }
}

pub fn parse_log_config<F>(
  val: &Val<'_>,
  mut resolve_path: F,
) -> Result<Option<LogConfig>>
where
  F: FnMut(&str) -> Result<PathBuf>,
{
  match val.raw() {
    Value::Null => Ok(None),
    Value::Bool(false) => Ok(Some(LogConfig::disabled())),
    Value::Bool(true) => Ok(Some(LogConfig {
      enabled: Some(true),
      dir: None,
      file: None,
      mode: None,
    })),
    Value::Number(_) => {
      Err(val.error_at("Expected bool, string, object, or null"))
    }
    Value::String(dir) => Ok(Some(LogConfig::with_dir(resolve_path(dir)?))),
    Value::Sequence(_) => {
      Err(val.error_at("Expected bool, string, object, or null"))
    }
    Value::Mapping(_) => {
      let map = val.as_object()?;

      let enabled = map
        .get(&Value::from("enabled"))
        .map_or(Ok(true), |v| v.as_bool())?;

      let dir = match map.get(&Value::from("dir")) {
        Some(dir) => match dir.raw() {
          Value::Null => None,
          Value::String(path) => Some(resolve_path(path)?),
          _ => return Err(dir.error_at("Expected string or null")),
        },
        None => None,
      };

      let file = match map.get(&Value::from("file")) {
        Some(file) => match file.raw() {
          Value::Null => None,
          Value::String(path) => Some(resolve_path(path)?),
          _ => return Err(file.error_at("Expected string or null")),
        },
        None => None,
      };

      let mode = match (
        map.get(&Value::from("mode")),
        map.get(&Value::from("append")),
      ) {
        (Some(_), Some(append)) => {
          return Err(
            append.error_at("Use either `mode` or `append`, not both"),
          );
        }
        (Some(mode), None) => match mode.as_str()? {
          "append" => LogMode::Append,
          "truncate" => LogMode::Truncate,
          _ => return Err(mode.error_at("Expected `append` or `truncate`")),
        },
        (None, Some(append)) => {
          if append.as_bool()? {
            LogMode::Append
          } else {
            LogMode::Truncate
          }
        }
        (None, None) => LogMode::Truncate,
      };

      Ok(Some(LogConfig {
        enabled: Some(enabled),
        dir,
        file,
        mode: Some(mode),
      }))
    }
    Value::Tagged(_) => anyhow::bail!("Yaml tags are not supported"),
  }
}

fn expand_template(
  template: &str,
  proc_name: &str,
  proc_id: usize,
  pid: u32,
) -> String {
  let ts = SystemTime::now()
    .duration_since(UNIX_EPOCH)
    .map(|duration| duration.as_secs())
    .unwrap_or(0);

  template
    .replace(
      "{name}",
      &default_log_filename(proc_name).trim_end_matches(".log"),
    )
    .replace("{id}", &proc_id.to_string())
    .replace("{pid}", &pid.to_string())
    .replace("{ts}", &ts.to_string())
}