whoseportisitanyway 1.1.2

Cross-platform TUI for discovering which ports are in use, who owns them, and what they're for
Documentation
pub mod defaults;

use std::path::PathBuf;

use anyhow::{Context, Result};
use serde::Deserialize;

use self::defaults::*;

#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Config {
    pub refresh_interval_secs: u64,
    pub default_sort: String,
    pub show_established: bool,
    pub project_markers: Vec<String>,
    pub watched_ports: Vec<u16>,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            refresh_interval_secs: DEFAULT_REFRESH_INTERVAL_SECS,
            default_sort: DEFAULT_SORT.to_string(),
            show_established: DEFAULT_SHOW_ESTABLISHED,
            project_markers: DEFAULT_PROJECT_MARKERS
                .iter()
                .map(|s| (*s).to_string())
                .collect(),
            watched_ports: Vec::new(),
        }
    }
}

impl Config {
    pub fn load() -> Result<Self> {
        let path = Self::config_path();
        if path.exists() {
            let content = std::fs::read_to_string(&path)
                .with_context(|| format!("failed to read config from {}", path.display()))?;
            let config: Config = toml::from_str(&content)
                .with_context(|| format!("failed to parse config from {}", path.display()))?;
            Ok(config)
        } else {
            Ok(Config::default())
        }
    }

    fn config_path() -> PathBuf {
        dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("whoseportisitanyway")
            .join("config.toml")
    }
}

#[cfg(test)]
mod tests {
    use super::defaults::*;
    use super::*;

    #[test]
    fn default_config_values() {
        let cfg = Config::default();
        assert_eq!(cfg.refresh_interval_secs, DEFAULT_REFRESH_INTERVAL_SECS);
        assert_eq!(cfg.default_sort, DEFAULT_SORT);
        assert_eq!(cfg.show_established, DEFAULT_SHOW_ESTABLISHED);
        assert!(cfg.watched_ports.is_empty());
    }

    #[test]
    fn default_project_markers_not_empty() {
        let cfg = Config::default();
        assert!(!cfg.project_markers.is_empty());
        assert!(cfg.project_markers.contains(&"package.json".to_string()));
        assert!(cfg.project_markers.contains(&"Cargo.toml".to_string()));
    }

    #[test]
    fn deserialize_partial_toml() {
        let toml_str = r#"
            refresh_interval_secs = 5
            watched_ports = [3000, 8080]
        "#;
        let cfg: Config = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.refresh_interval_secs, 5);
        assert_eq!(cfg.watched_ports, vec![3000, 8080]);
        assert_eq!(cfg.default_sort, DEFAULT_SORT);
        assert_eq!(cfg.show_established, DEFAULT_SHOW_ESTABLISHED);
    }

    #[test]
    fn deserialize_full_toml() {
        let toml_str = r#"
            refresh_interval_secs = 10
            default_sort = "pid"
            show_established = true
            project_markers = ["go.mod"]
            watched_ports = [443]
        "#;
        let cfg: Config = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.refresh_interval_secs, 10);
        assert_eq!(cfg.default_sort, "pid");
        assert!(cfg.show_established);
        assert_eq!(cfg.project_markers, vec!["go.mod"]);
        assert_eq!(cfg.watched_ports, vec![443]);
    }

    #[test]
    fn deserialize_empty_toml() {
        let cfg: Config = toml::from_str("").unwrap();
        assert_eq!(cfg.refresh_interval_secs, DEFAULT_REFRESH_INTERVAL_SECS);
    }

    #[test]
    fn load_returns_default_when_no_file() {
        let cfg = Config::load().unwrap();
        assert_eq!(cfg.refresh_interval_secs, DEFAULT_REFRESH_INTERVAL_SECS);
    }

    #[test]
    fn config_path_ends_with_config_toml() {
        let path = Config::config_path();
        assert!(path.ends_with("whoseportisitanyway/config.toml"));
    }
}