use actl_core::{CtlError, ErrorCode};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
fn error(message: impl Into<String>) -> CtlError {
CtlError::protocol(message)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Discovery {
Auto,
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved: Option<String>,
pub discovered: Discovery,
pub last_verified_unix: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct Registry {
version: u32,
apps: std::collections::BTreeMap<String, AppEntry>,
}
impl Registry {
fn new() -> Self {
Self {
version: 1,
apps: std::collections::BTreeMap::new(),
}
}
}
fn registry_path() -> PathBuf {
actl_core::state::SignalPaths::default()
.dir
.join("apps.json")
}
fn load() -> Result<Registry, CtlError> {
let path = registry_path();
if !path.exists() {
return Ok(Registry::new());
}
let text = std::fs::read_to_string(&path)
.map_err(|e| CtlError::internal(format!("cannot read apps.json: {e}")))?;
if text.trim().is_empty() {
return Ok(Registry::new());
}
serde_json::from_str(&text)
.map_err(|e| CtlError::protocol(format!("apps.json is not valid YAML: {e}")))
}
fn save(registry: &Registry) -> Result<(), CtlError> {
let path = registry_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CtlError::internal(format!("cannot create actl dir: {e}")))?;
}
let text = serde_json::to_string_pretty(registry)
.map_err(|e| CtlError::internal(format!("cannot serialize apps.json: {e}")))?;
std::fs::write(&path, text)
.map_err(|e| CtlError::internal(format!("cannot write apps.json: {e}")))
}
pub fn register(name: &str, path: &str, manual: bool) -> Result<(), CtlError> {
validate_name(name)?;
if path.is_empty() || path.len() > 1024 {
return Err(error("path must be 1-1024 characters"));
}
let p = Path::new(path);
if !p.is_absolute() {
return Err(error(format!("path must be absolute: {path}")));
}
let mut reg = load()?;
reg.apps.insert(
name.to_string(),
AppEntry {
path: Some(path.to_string()),
pattern: None,
resolved: None,
discovered: if manual {
Discovery::Manual
} else {
Discovery::Auto
},
last_verified_unix: now_unix(),
},
);
save(®)
}
pub fn register_pattern(name: &str, pattern: &str) -> Result<(), CtlError> {
validate_name(name)?;
if pattern.is_empty() || pattern.len() > 1024 || !pattern.contains('*') {
return Err(error(
"pattern must be 1-1024 characters and contain at least one *",
));
}
let mut reg = load()?;
reg.apps.insert(
name.to_string(),
AppEntry {
path: None,
pattern: Some(pattern.to_string()),
resolved: None,
discovered: Discovery::Manual,
last_verified_unix: 0,
},
);
save(®)
}
pub fn resolve(name: &str) -> Result<Option<String>, CtlError> {
let mut reg = load()?;
let Some(entry) = reg.apps.get(name).cloned() else {
return Ok(None);
};
if let Some(path) = &entry.path {
if Path::new(path).is_file() {
return Ok(Some(path.clone()));
}
return Ok(Some(path.clone())); }
if let Some(pattern) = &entry.pattern {
if let Some(resolved) = &entry.resolved
&& Path::new(resolved).is_file()
{
return Ok(Some(resolved.clone()));
}
if let Some(found) = glob_resolve(pattern)? {
if let Some(entry) = reg.apps.get_mut(name) {
entry.resolved = Some(found.clone());
entry.last_verified_unix = now_unix();
}
let _ = save(®);
return Ok(Some(found));
}
return Ok(None);
}
Ok(None)
}
pub fn list() -> Result<serde_json::Value, CtlError> {
let reg = load()?;
let apps: Vec<serde_json::Value> = reg
.apps
.iter()
.map(|(name, e)| {
serde_json::json!({
"name": name,
"path": e.path,
"pattern": e.pattern,
"resolved": e.resolved,
"discovered": e.discovered,
"last_verified_unix": e.last_verified_unix,
})
})
.collect();
Ok(serde_json::json!({ "apps": apps, "total": apps.len() }))
}
pub fn remove(name: &str) -> Result<(), CtlError> {
let mut reg = load()?;
if reg.apps.remove(name).is_none() {
return Err(CtlError::new(
ErrorCode::NotFound,
format!("app {name:?} is not registered"),
));
}
save(®)
}
fn validate_name(name: &str) -> Result<(), CtlError> {
let ok = !name.is_empty()
&& name.len() <= 64
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
if ok {
Ok(())
} else {
Err(error("app name must be 1-64 of [a-zA-Z0-9_-]"))
}
}
fn glob_resolve(pattern: &str) -> Result<Option<String>, CtlError> {
let normalized = pattern.replace('\\', "/");
let segments: Vec<&str> = normalized.split('/').collect();
let mut candidates: Vec<PathBuf> = vec![PathBuf::new()];
for seg in &segments {
let mut next = Vec::new();
if seg.contains('*') {
let (prefix, suffix) = seg.split_once('*').unwrap_or((seg, ""));
for base in &candidates {
if let Ok(entries) = std::fs::read_dir(base) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with(prefix) && name.ends_with(suffix) {
next.push(entry.path());
}
}
}
}
} else if candidates.len() == 1
&& candidates[0].as_os_str().is_empty()
&& seg.ends_with(':')
{
next.push(PathBuf::from(format!("{seg}/")));
} else {
for base in &candidates {
next.push(base.join(seg));
}
}
candidates = next;
if candidates.is_empty() {
return Ok(None);
}
}
for c in &candidates {
if c.is_file() {
return Ok(Some(c.to_string_lossy().replace('\\', "/")));
}
}
Ok(None)
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_resolves_wildcard_directory() {
let dir = std::env::temp_dir().join("actl-app-registry-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Microsoft.Paint_11.2605.81.0").join("PaintApp")).unwrap();
std::fs::create_dir_all(dir.join("Microsoft.Paint_11.2607.14.0").join("PaintApp")).unwrap();
std::fs::write(
dir.join("Microsoft.Paint_11.2607.14.0")
.join("PaintApp")
.join("mspaint.exe"),
b"fake",
)
.unwrap();
let pattern = format!(
"{}/Microsoft.Paint_*/PaintApp/mspaint.exe",
dir.to_string_lossy().replace('\\', "/")
);
let found = glob_resolve(&pattern).unwrap();
assert!(found.is_some(), "should find via wildcard");
assert!(found.unwrap().contains("Paint_11.2607"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn glob_returns_none_on_miss() {
assert!(glob_resolve("C:/nonexistent-*/app.exe").unwrap().is_none());
}
#[test]
fn name_validation_rejects_bad() {
assert!(validate_name("my-app").is_ok());
assert!(validate_name("my_app").is_ok());
assert!(validate_name("MyApp123").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name("../evil").is_err());
assert!(validate_name("has space").is_err());
assert!(validate_name(&"x".repeat(65)).is_err());
}
}