mechutil 0.8.8

Utility structures and functions for mechatronics applications.
Documentation
//! Runtime settings for registered tools.
//!
//! A tool's *settings* are the admin-chosen, mutable state (effective port,
//! bind address, enabled/disabled) plus whatever private state the tool wants
//! to keep. They live in `<config>/tool-settings/<name>.json` — a directory
//! *separate* from the package-owned manifests in `tools.d/`, so package
//! upgrades never clobber field choices.
//!
//! Settings are two-tier on purpose: a reserved [`LaunchSettings`] block that
//! `autocore-server` reads to decide how to launch a service tool, and a
//! free-form [`ToolSettings::extra`] object the server never parses, so a tool
//! can store anything it likes without breaking the launch contract.
//!
//! See `doc/tool-registry.md` for the full contract.

use std::fs;
use std::path::PathBuf;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;

use crate::paths::resolve_config_dir;
use crate::tool_registry::ToolManifest;

/// Errors from settings operations.
#[derive(Debug, Error)]
pub enum ToolSettingsError {
    #[error("io error on {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("could not parse settings {path}: {source}")]
    Parse {
        path: PathBuf,
        #[source]
        source: serde_json::Error,
    },
}

type Result<T> = std::result::Result<T, ToolSettingsError>;

/// The reserved launch block. `autocore-server` reads exactly these fields to
/// decide whether and how to supervise a service tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LaunchSettings {
    /// Whether the tool may run at all.
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Effective HTTP port for a tool that serves a UI.
    #[serde(default)]
    pub port: Option<u16>,
    /// Address the tool's HTTP server binds to. Defaults to loopback: a
    /// `serves_http` tool is an unauthenticated surface and must not be exposed
    /// on a routable address until autocore user-auth lands.
    #[serde(default = "default_bind")]
    pub bind: String,
    /// Whether the tool starts together with the server (service tools only).
    #[serde(default = "default_true")]
    pub autostart: bool,
}

fn default_true() -> bool {
    true
}
fn default_bind() -> String {
    "127.0.0.1".to_string()
}

impl Default for LaunchSettings {
    fn default() -> Self {
        LaunchSettings {
            enabled: true,
            port: None,
            bind: default_bind(),
            autostart: true,
        }
    }
}

/// A tool's persisted settings: the reserved [`LaunchSettings`] plus a
/// free-form, tool-private `extra` object.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ToolSettings {
    #[serde(default)]
    pub launch: LaunchSettings,
    /// Arbitrary tool-private state; the server never parses this.
    #[serde(default = "empty_object")]
    pub extra: Value,
    /// Tool name this settings file belongs to (not serialized; set on load).
    #[serde(skip)]
    name: String,
}

fn empty_object() -> Value {
    Value::Object(serde_json::Map::new())
}

impl Default for ToolSettings {
    fn default() -> Self {
        ToolSettings {
            launch: LaunchSettings::default(),
            extra: empty_object(),
            name: String::new(),
        }
    }
}

/// The settings directory, `<config>/tool-settings`.
pub fn settings_dir() -> PathBuf {
    resolve_config_dir().join("tool-settings")
}

fn settings_path(name: &str) -> PathBuf {
    settings_dir().join(format!("{name}.json"))
}

impl ToolSettings {
    /// Open a tool's settings, creating defaults in memory if the file does not
    /// yet exist (nothing is written until [`save`](Self::save)).
    pub fn open(name: &str) -> Result<ToolSettings> {
        let path = settings_path(name);
        if !path.is_file() {
            let mut s = ToolSettings::default();
            s.name = name.to_string();
            return Ok(s);
        }
        let text = fs::read_to_string(&path).map_err(|source| ToolSettingsError::Io {
            path: path.clone(),
            source,
        })?;
        let mut s: ToolSettings =
            serde_json::from_str(&text).map_err(|source| ToolSettingsError::Parse {
                path: path.clone(),
                source,
            })?;
        s.name = name.to_string();
        Ok(s)
    }

    /// Open a tool's settings, seeding any *unset* launch fields from the
    /// tool's manifest defaults on first run. Existing on-disk settings always
    /// win — this only fills gaps so the server has a port/autostart to use
    /// before an admin has configured anything.
    pub fn open_seeded(manifest: &ToolManifest) -> Result<ToolSettings> {
        let existed = settings_path(&manifest.name).is_file();
        let mut s = ToolSettings::open(&manifest.name)?;
        if !existed {
            if s.launch.port.is_none() {
                s.launch.port = manifest.launch.default_port;
            }
            s.launch.autostart = manifest.launch.autostart;
        }
        Ok(s)
    }

    /// The tool name these settings belong to.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Persist the settings atomically (temp file in the same directory, then
    /// rename), so a reader never sees a half-written file.
    pub fn save(&self) -> Result<()> {
        let dir = settings_dir();
        fs::create_dir_all(&dir).map_err(|source| ToolSettingsError::Io {
            path: dir.clone(),
            source,
        })?;
        let dest = settings_path(&self.name);
        let text =
            serde_json::to_string_pretty(self).map_err(|source| ToolSettingsError::Parse {
                path: dest.clone(),
                source,
            })?;
        let tmp = dir.join(format!(".{}.tmp.{}", self.name, std::process::id()));
        fs::write(&tmp, text.as_bytes()).map_err(|source| ToolSettingsError::Io {
            path: tmp.clone(),
            source,
        })?;
        fs::rename(&tmp, &dest).map_err(|source| ToolSettingsError::Io {
            path: dest.clone(),
            source,
        })?;
        Ok(())
    }
}

/// Convenience used by `autocore-server`: the effective bind address + port for
/// a service tool, or `None` if it has no configured port.
pub fn effective_http_addr(settings: &ToolSettings) -> Option<String> {
    settings
        .launch
        .port
        .map(|p| format!("{}:{}", settings.launch.bind, p))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::paths::test_support::with_temp_config;
    use crate::tool_registry::{LaunchMode, LaunchSpec};

    fn manifest() -> ToolManifest {
        ToolManifest {
            schema_version: 1,
            name: "labelit-studio".into(),
            version: "1.1.3".into(),
            description: None,
            executable: "/opt/autocore/bin/modules/labelit-studio".into(),
            serves_http: true,
            ui_path: Some("/".into()),
            launch: LaunchSpec {
                mode: LaunchMode::Service,
                default_port: Some(7878),
                autostart: true,
                args: vec![],
            },
            editors: vec![],
        }
    }

    #[test]
    fn defaults_are_safe() {
        let s = ToolSettings::default();
        assert!(s.launch.enabled);
        assert_eq!(s.launch.bind, "127.0.0.1", "must default to loopback");
        assert!(s.extra.is_object());
    }

    #[test]
    fn open_seeded_fills_from_manifest_then_persists() {
        with_temp_config("seed", || {
            // First open: no file yet, port seeded from the manifest.
            let mut s = ToolSettings::open_seeded(&manifest()).unwrap();
            assert_eq!(s.launch.port, Some(7878));
            s.launch.port = Some(7900); // admin overrides
            s.extra["last_image_dir"] = Value::String("/snaps".into());
            s.save().unwrap();

            // Reopen: on-disk value wins, manifest does not re-seed over it.
            let mut m = manifest();
            m.launch.default_port = Some(1234);
            let reopened = ToolSettings::open_seeded(&m).unwrap();
            assert_eq!(reopened.launch.port, Some(7900));
            assert_eq!(reopened.extra["last_image_dir"], "/snaps");
            assert_eq!(reopened.launch.bind, "127.0.0.1");
        });
    }

    #[test]
    fn effective_addr() {
        let mut s = ToolSettings::default();
        assert!(effective_http_addr(&s).is_none());
        s.launch.port = Some(7878);
        assert_eq!(effective_http_addr(&s).as_deref(), Some("127.0.0.1:7878"));
    }
}