use std::fs;
use std::path::{Path, PathBuf};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::paths::resolve_config_dir;
#[derive(Debug, Error)]
pub enum ToolRegistryError {
#[error("io error on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("could not parse manifest {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("invalid manifest '{name}': {reason}")]
Validation { name: String, reason: String },
#[error("no tool named '{0}' is registered")]
NotFound(String),
}
type Result<T> = std::result::Result<T, ToolRegistryError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum LaunchMode {
Service,
OnDemand,
}
impl Default for LaunchMode {
fn default() -> Self {
LaunchMode::OnDemand
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LaunchSpec {
#[serde(default)]
pub mode: LaunchMode,
#[serde(default)]
pub default_port: Option<u16>,
#[serde(default = "default_true")]
pub autostart: bool,
#[serde(default)]
pub args: Vec<String>,
}
impl Default for LaunchSpec {
fn default() -> Self {
LaunchSpec {
mode: LaunchMode::default(),
default_port: None,
autostart: true,
args: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EditorContribution {
pub target_domain: String,
#[serde(default)]
pub target_path: Option<String>,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ToolManifest {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: Option<String>,
pub executable: String,
#[serde(default)]
pub serves_http: bool,
#[serde(default)]
pub ui_path: Option<String>,
#[serde(default)]
pub launch: LaunchSpec,
#[serde(default)]
pub editors: Vec<EditorContribution>,
}
fn default_true() -> bool {
true
}
fn default_schema_version() -> u32 {
1
}
pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
impl ToolManifest {
pub fn validate(&self) -> Result<()> {
let bad = |reason: &str| ToolRegistryError::Validation {
name: self.name.clone(),
reason: reason.to_string(),
};
if self.name.trim().is_empty() {
return Err(bad("name is empty"));
}
if self
.name
.contains(['/', '\\', ' ', '.'])
{
return Err(bad(
"name must be kebab-case with no path separators, spaces, or dots",
));
}
if self.executable.trim().is_empty() {
return Err(bad("executable is empty"));
}
if self.schema_version > MANIFEST_SCHEMA_VERSION {
return Err(bad(&format!(
"schema_version {} is newer than supported {}",
self.schema_version, MANIFEST_SCHEMA_VERSION
)));
}
for e in &self.editors {
if e.target_domain.trim().is_empty() {
return Err(bad("an editor contribution has an empty target_domain"));
}
}
Ok(())
}
pub fn is_service(&self) -> bool {
self.launch.mode == LaunchMode::Service
}
}
pub fn tools_dir() -> PathBuf {
resolve_config_dir().join("tools.d")
}
fn read_manifest(path: &Path) -> Result<ToolManifest> {
let text = fs::read_to_string(path).map_err(|source| ToolRegistryError::Io {
path: path.to_path_buf(),
source,
})?;
let manifest: ToolManifest =
serde_json::from_str(&text).map_err(|source| ToolRegistryError::Parse {
path: path.to_path_buf(),
source,
})?;
manifest.validate()?;
Ok(manifest)
}
pub fn load_tool(name: &str) -> Result<ToolManifest> {
let dir = tools_dir();
let flat = dir.join(format!("{name}.json"));
if flat.is_file() {
return read_manifest(&flat);
}
let nested = dir.join(name).join("manifest.json");
if nested.is_file() {
return read_manifest(&nested);
}
Err(ToolRegistryError::NotFound(name.to_string()))
}
pub fn list_tools() -> Vec<ToolManifest> {
let dir = tools_dir();
let mut out = Vec::new();
let entries = match fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return out, };
for entry in entries.flatten() {
let path = entry.path();
let manifest_path = if path.is_dir() {
let m = path.join("manifest.json");
if m.is_file() {
m
} else {
continue;
}
} else if path.extension().and_then(|e| e.to_str()) == Some("json") {
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(true)
{
continue;
}
path.clone()
} else {
continue;
};
match read_manifest(&manifest_path) {
Ok(m) => {
let stem = manifest_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if !manifest_path.ends_with("manifest.json") && stem != m.name {
log::warn!(
"tool manifest {:?}: file stem '{}' does not match name '{}'",
manifest_path,
stem,
m.name
);
}
out.push(m);
}
Err(e) => log::warn!("skipping tool manifest {:?}: {}", manifest_path, e),
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn find_editor_for(
domain: &str,
path: Option<&str>,
) -> Option<(ToolManifest, EditorContribution)> {
let tools = list_tools();
let mut whole_module: Option<(ToolManifest, EditorContribution)> = None;
for tool in tools {
for editor in &tool.editors {
if editor.target_domain != domain {
continue;
}
match (&editor.target_path, path) {
(Some(tp), Some(p)) if tp == p => {
return Some((tool.clone(), editor.clone()));
}
(None, _) => {
if whole_module.is_none() {
whole_module = Some((tool.clone(), editor.clone()));
}
}
_ => {}
}
}
}
whole_module
}
pub fn register_tool(manifest: &ToolManifest) -> Result<()> {
manifest.validate()?;
let dir = tools_dir();
fs::create_dir_all(&dir).map_err(|source| ToolRegistryError::Io {
path: dir.clone(),
source,
})?;
let dest = dir.join(format!("{}.json", manifest.name));
let text = serde_json::to_string_pretty(manifest).map_err(|source| {
ToolRegistryError::Parse {
path: dest.clone(),
source,
}
})?;
write_atomic(&dest, text.as_bytes())?;
Ok(())
}
pub fn unregister_tool(name: &str) -> Result<()> {
let dir = tools_dir();
let flat = dir.join(format!("{name}.json"));
let nested = dir.join(name);
let mut removed = false;
if flat.is_file() {
fs::remove_file(&flat).map_err(|source| ToolRegistryError::Io {
path: flat.clone(),
source,
})?;
removed = true;
}
if nested.is_dir() {
fs::remove_dir_all(&nested).map_err(|source| ToolRegistryError::Io {
path: nested.clone(),
source,
})?;
removed = true;
}
if removed {
Ok(())
} else {
Err(ToolRegistryError::NotFound(name.to_string()))
}
}
fn write_atomic(dest: &Path, bytes: &[u8]) -> Result<()> {
let dir = dest.parent().unwrap_or_else(|| Path::new("."));
let tmp = dir.join(format!(
".{}.tmp.{}",
dest.file_name()
.and_then(|n| n.to_str())
.unwrap_or("manifest"),
std::process::id()
));
fs::write(&tmp, bytes).map_err(|source| ToolRegistryError::Io {
path: tmp.clone(),
source,
})?;
fs::rename(&tmp, dest).map_err(|source| ToolRegistryError::Io {
path: dest.to_path_buf(),
source,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::test_support::with_temp_config;
fn sample() -> ToolManifest {
ToolManifest {
schema_version: 1,
name: "labelit-studio".into(),
version: "1.1.3".into(),
description: Some("Vision editor".into()),
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![EditorContribution {
target_domain: "labelit".into(),
target_path: None,
label: "Vision Editor".into(),
}],
}
}
#[test]
fn register_list_find_unregister_roundtrip() {
with_temp_config("roundtrip", || {
assert!(list_tools().is_empty());
register_tool(&sample()).unwrap();
let tools = list_tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "labelit-studio");
assert!(tools[0].is_service());
let loaded = load_tool("labelit-studio").unwrap();
assert_eq!(loaded.launch.default_port, Some(7878));
let (tool, ed) = find_editor_for("labelit", None).expect("editor found");
assert_eq!(tool.name, "labelit-studio");
assert_eq!(ed.label, "Vision Editor");
assert!(find_editor_for("labelit", Some("/robot_calibration")).is_some());
assert!(find_editor_for("ethercat", None).is_none());
unregister_tool("labelit-studio").unwrap();
assert!(list_tools().is_empty());
assert!(matches!(
load_tool("labelit-studio"),
Err(ToolRegistryError::NotFound(_))
));
});
}
#[test]
fn exact_path_editor_beats_whole_module() {
with_temp_config("pathmatch", || {
let mut m = sample();
m.name = "robot-cal-widget".into();
m.editors = vec![EditorContribution {
target_domain: "labelit".into(),
target_path: Some("/robot_calibration".into()),
label: "Robot Calibration".into(),
}];
register_tool(&m).unwrap();
register_tool(&sample()).unwrap();
let (_, ed) =
find_editor_for("labelit", Some("/robot_calibration")).expect("editor");
assert_eq!(ed.label, "Robot Calibration");
});
}
#[test]
fn rejects_invalid_names() {
with_temp_config("invalid", || {
let mut m = sample();
m.name = "has space".into();
assert!(matches!(
register_tool(&m),
Err(ToolRegistryError::Validation { .. })
));
});
}
#[test]
fn bad_manifest_is_skipped_not_fatal() {
with_temp_config("badfile", || {
register_tool(&sample()).unwrap();
fs::write(tools_dir().join("broken.json"), b"{ not json").unwrap();
let tools = list_tools();
assert_eq!(tools.len(), 1, "valid tool survives a broken neighbor");
assert_eq!(tools[0].name, "labelit-studio");
});
}
}