use super::{name_ok, LINES_DEFAULT, LINES_MAX, LINES_MIN};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const FOLDER_MAX: u64 = 64 * 1024;
pub const EVERY_MIN: u64 = 5;
pub const EVERY_DEFAULT: u64 = 60;
pub const TIMEOUT_MAX: u64 = 30;
pub const TIMEOUT_DEFAULT: u64 = 10;
pub fn dir(config_dir: &Path) -> PathBuf {
config_dir.join("widgets")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
#[default]
Desk,
Global,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Run {
pub command: String,
#[serde(default = "every_default")]
pub every: u64,
#[serde(default = "timeout_default")]
pub timeout: u64,
}
fn every_default() -> u64 {
EVERY_DEFAULT
}
fn timeout_default() -> u64 {
TIMEOUT_DEFAULT
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Field {
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub default: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Spec {
pub name: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub scope: Scope,
pub run: Run,
#[serde(default = "lines_default")]
pub lines: u8,
#[serde(default)]
pub settings: BTreeMap<String, Field>,
}
fn lines_default() -> u8 {
LINES_DEFAULT
}
impl Spec {
fn bounded(mut self) -> Spec {
self.run.every = self.run.every.max(EVERY_MIN);
self.run.timeout = self.run.timeout.clamp(1, TIMEOUT_MAX);
self.lines = self.lines.clamp(LINES_MIN, LINES_MAX);
if self.title.trim().is_empty() {
self.title = super::title_of(&self.name);
}
self.title = self.title.trim().chars().take(40).collect();
self
}
pub fn settings_with(&self, reader: &str) -> Value {
let set: serde_json::Map<String, Value> = serde_json::from_str(reader).unwrap_or_default();
let mut out = serde_json::Map::new();
for (k, f) in &self.settings {
out.insert(
k.clone(),
set.get(k).cloned().unwrap_or_else(|| f.default.clone()),
);
}
Value::Object(out)
}
}
#[derive(Debug, Clone)]
pub struct Found {
pub name: String,
pub folder: PathBuf,
pub spec: Result<Spec, String>,
}
pub fn scan(config_dir: &Path) -> Vec<Found> {
let Ok(rd) = std::fs::read_dir(dir(config_dir)) else {
return Vec::new();
};
let mut out: Vec<Found> = rd
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
name_ok(&name).then(|| Found {
spec: read(&e.path(), &name),
name,
folder: e.path(),
})
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn read(folder: &Path, name: &str) -> Result<Spec, String> {
let text = std::fs::read_to_string(folder.join("widget.json"))
.map_err(|_| "it has no widget.json".to_string())?;
let spec: Spec =
serde_json::from_str(&text).map_err(|e| format!("widget.json does not read: {e}"))?;
if spec.name != name {
return Err(format!(
"widget.json says \"{}\", in a folder called {name}",
spec.name
));
}
if spec.run.command.trim().is_empty() {
return Err("widget.json has no run.command".into());
}
Ok(spec.bounded())
}
pub fn stamp(folder: &Path) -> Option<(u128, u64, usize)> {
let mut newest = 0u128;
let (mut bytes, mut n) = (0u64, 0usize);
for f in files(folder) {
let m = std::fs::metadata(&f).ok()?;
let t = m
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
newest = newest.max(t);
bytes += m.len();
n += 1;
}
Some((newest, bytes, n))
}
fn files(folder: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut todo = vec![folder.to_path_buf()];
while let Some(d) = todo.pop() {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for e in rd.flatten() {
let Ok(t) = e.file_type() else { continue };
if t.is_dir() {
todo.push(e.path());
} else if t.is_file() {
out.push(e.path());
}
}
}
out.sort();
out
}
pub fn hash(folder: &Path) -> Result<String, String> {
let mut h = blake3::Hasher::new();
let mut total = 0u64;
for f in files(folder) {
let rel = f
.strip_prefix(folder)
.unwrap_or(&f)
.to_string_lossy()
.replace('\\', "/");
let bytes = std::fs::read(&f).map_err(|e| format!("could not read {rel}: {e}"))?;
total += bytes.len() as u64;
if total > FOLDER_MAX {
return Err(format!(
"the folder holds more than {} KB",
FOLDER_MAX / 1024
));
}
h.update(rel.as_bytes());
h.update(&[0]);
h.update(&(bytes.len() as u64).to_le_bytes());
h.update(&bytes);
}
Ok(h.finalize().to_hex().to_string())
}
const DIR_VAR: &str = if cfg!(windows) {
"%SNYVI_WIDGET_DIR%"
} else {
"$SNYVI_WIDGET_DIR"
};
pub fn lint(spec: &Spec, folder: &Path) -> Result<(), String> {
if spec.scope == Scope::Global {
return Ok(());
}
let ends = |w: &str| matches!(w, ";" | "&&" | "||" | "|" | "&" | "(" | "{");
let mut first = true;
for raw in spec.run.command.split_whitespace() {
if ends(raw) {
first = true;
continue;
}
let then_first = raw.ends_with(';') || raw.ends_with('&') || raw.ends_with('|');
let w = raw
.trim_end_matches([';', '&', '|'])
.trim_matches(|c| c == '"' || c == '\'');
let skip = w.is_empty()
|| w.starts_with(['-', '$', '%', '~', '/', '\\'])
|| (first && w.contains('='))
|| w.contains("://");
if !skip {
let rel = w
.strip_prefix("./")
.or_else(|| w.strip_prefix(".\\"))
.unwrap_or(w);
let here = !rel.is_empty() && !rel.starts_with("..") && folder.join(rel).is_file();
let pathed = w.contains(['/', '\\']);
if here && (pathed || !first) {
let sep = if cfg!(windows) { "\\" } else { "/" };
return Err(format!(
"{rel} is in the widget's folder, but a desk widget runs in the desk's folder, where it is not: \
write \"{DIR_VAR}{sep}{rel}\" instead, or {rel} alone as the command for a script that runs by itself"
));
}
if here && first && !runnable(&folder.join(rel)) {
return Err(format!(
"{rel} is named as the command but cannot run by itself: make it executable, \
or write sh \"{DIR_VAR}/{rel}\""
));
}
}
if !(first && w.contains('=')) {
first = false;
}
if then_first {
first = true;
}
}
Ok(())
}
fn runnable(f: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(f).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
f.extension().is_some()
}
}
pub fn starter(name: &str, scope: Scope) -> (String, &'static str, String) {
let script = if cfg!(windows) { "run.ps1" } else { "run.sh" };
let command = if cfg!(windows) {
format!("powershell -NoProfile -ExecutionPolicy Bypass -File \"{DIR_VAR}\\run.ps1\"")
} else {
"run.sh".to_string()
};
let spec = Spec {
name: name.to_string(),
title: super::title_of(name),
scope,
run: Run {
command,
every: 30,
timeout: 5,
},
lines: 2,
settings: BTreeMap::new(),
};
let json = serde_json::to_string_pretty(&spec).unwrap_or_default() + "\n";
let body = if cfg!(windows) {
"# stdin: {\"desk\":{...},\"settings\":{...},\"snyvi\":\"<version>\"}\n\
# Print Markdown, or one JSON object: {\"body\":\"...\",\"tone\":\"ok\",\"count\":\"3\"}\n\
$now = Get-Date -Format HH:mm\n\
Write-Output \"**$(Split-Path -Leaf (Get-Location))** · $now\"\n"
.to_string()
} else {
"#!/bin/sh\n\
# stdin: {\"desk\":{...},\"settings\":{...},\"snyvi\":\"<version>\"}\n\
# cwd: the desk's folder (a desk widget) or this folder (a global one);\n\
# this folder is $SNYVI_WIDGET_DIR, first on PATH.\n\
# Print Markdown, or one JSON object: {\"body\":\"...\",\"tone\":\"ok\",\"count\":\"3\"}\n\
printf '**%s** · %s\\n' \"$(basename \"$PWD\")\" \"$(date +%H:%M)\"\n"
.to_string()
};
(json, script, body)
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("snyvi-wfiles-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(dir(&d).join("git")).unwrap();
d
}
#[test]
fn a_spec_is_read_and_bounded() {
let cfg = folder("read");
let w = dir(&cfg).join("git");
std::fs::write(
w.join("widget.json"),
r#"{"name":"git","run":{"command":"./run.sh","every":1,"timeout":99},"lines":9,
"settings":{"base":{"type":"string","default":"main"}}}"#,
)
.unwrap();
let found = scan(&cfg);
assert_eq!(found.len(), 1);
let s = found[0].spec.clone().unwrap();
assert_eq!(
(s.run.every, s.run.timeout, s.lines),
(EVERY_MIN, TIMEOUT_MAX, LINES_MAX)
);
assert_eq!(s.title, "Git");
assert_eq!(
s.settings_with(r#"{"base":"dev","other":1}"#),
serde_json::json!({ "base": "dev" })
);
assert_eq!(s.settings_with("{}"), serde_json::json!({ "base": "main" }));
let _ = std::fs::remove_dir_all(&cfg);
}
#[test]
fn a_folder_that_says_another_name_is_listed_with_why() {
let cfg = folder("name");
std::fs::write(
dir(&cfg).join("git").join("widget.json"),
r#"{"name":"ci","run":{"command":"x"}}"#,
)
.unwrap();
let found = scan(&cfg);
assert!(found[0]
.spec
.as_ref()
.unwrap_err()
.contains("in a folder called git"));
let _ = std::fs::remove_dir_all(&cfg);
}
#[test]
fn the_hash_covers_every_file_and_has_a_cap() {
let cfg = folder("hash");
let w = dir(&cfg).join("git");
std::fs::write(w.join("run.sh"), "echo a").unwrap();
let a = hash(&w).unwrap();
std::fs::create_dir_all(w.join("lib")).unwrap();
std::fs::write(w.join("lib").join("x.sh"), "echo b").unwrap();
let b = hash(&w).unwrap();
assert_ne!(a, b, "a file the script could source is in the hash");
assert_eq!(b, hash(&w).unwrap());
std::fs::write(w.join("big"), vec![b'x'; FOLDER_MAX as usize]).unwrap();
assert!(hash(&w).unwrap_err().contains("more than 64 KB"));
let _ = std::fs::remove_dir_all(&cfg);
}
#[cfg(unix)]
fn desk_spec(command: &str) -> Spec {
Spec {
name: "git".into(),
title: "Git".into(),
scope: Scope::Desk,
run: Run {
command: command.into(),
every: 30,
timeout: 5,
},
lines: 2,
settings: BTreeMap::new(),
}
}
#[cfg(unix)]
#[test]
fn a_desk_widget_must_reach_its_own_files() {
use std::os::unix::fs::PermissionsExt;
let cfg = folder("lint");
let w = dir(&cfg).join("git");
std::fs::write(w.join("run.sh"), "#!/bin/sh\necho hi\n").unwrap();
std::fs::set_permissions(w.join("run.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(w.join("x.js"), "1").unwrap();
for bad in [
"./run.sh",
"sh run.sh",
"node x.js",
"cd . && sh ./run.sh",
"true; node x.js",
] {
let why = lint(&desk_spec(bad), &w).unwrap_err();
assert!(why.contains("$SNYVI_WIDGET_DIR/"), "{bad}: {why}");
}
for good in [
"run.sh",
"sh \"$SNYVI_WIDGET_DIR/run.sh\"",
"node \"$SNYVI_WIDGET_DIR/x.js\"",
"git status --short",
"LANG=C run.sh",
"true && run.sh",
] {
assert_eq!(lint(&desk_spec(good), &w), Ok(()), "{good}");
}
std::fs::set_permissions(w.join("run.sh"), std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(lint(&desk_spec("run.sh"), &w)
.unwrap_err()
.contains("make it executable"));
let mut g = desk_spec("sh ./run.sh");
g.scope = Scope::Global;
assert_eq!(lint(&g, &w), Ok(()));
let _ = std::fs::remove_dir_all(&cfg);
}
#[cfg(unix)]
#[test]
fn a_starter_passes_its_own_check() {
use std::os::unix::fs::PermissionsExt;
let cfg = folder("starter");
let w = dir(&cfg).join("git");
let (json, script, body) = starter("git", Scope::Desk);
std::fs::write(w.join("widget.json"), json).unwrap();
std::fs::write(w.join(script), body).unwrap();
std::fs::set_permissions(w.join(script), std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = read(&w, "git").unwrap();
assert_eq!(lint(&spec, &w), Ok(()));
let _ = std::fs::remove_dir_all(&cfg);
}
#[test]
fn a_starter_reads_back() {
let (json, _, _) = starter("disk-free", Scope::Global);
let s: Spec = serde_json::from_str(&json).unwrap();
assert_eq!(
(s.name.as_str(), s.title.as_str(), s.scope),
("disk-free", "Disk free", Scope::Global)
);
}
}