use std::collections::{BTreeMap, BTreeSet};
use std::sync::OnceLock;
use sha2::{Digest, Sha256};
use crate::cst::{self, Cmd, Script, SimpleCmd, Word, WordPart};
use crate::registry;
const DEFAULT_LEVEL: &str = "SafeWrite";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedEntry {
pub name: String,
pub standalone: Vec<String>,
pub max_positional: usize,
pub level: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
AlreadyAllowed,
Unparseable,
RecognizedButDenied { names: Vec<String> },
Generated {
entries: Vec<GeneratedEntry>,
also_recognized: Vec<String>,
},
}
pub fn analyze(command: &str) -> Outcome {
if cst::command_verdict(command).is_allowed() {
return Outcome::AlreadyAllowed;
}
let Some(script) = cst::parse(command) else {
return Outcome::Unparseable;
};
let mut simples: Vec<&SimpleCmd> = Vec::new();
collect_script(&script, &mut simples);
let mut unknown: BTreeMap<String, (BTreeSet<String>, usize)> = BTreeMap::new();
let mut recognized: BTreeSet<String> = BTreeSet::new();
for sc in simples {
let Some(name) = command_basename(sc) else {
continue;
};
if is_known(&name) {
recognized.insert(name);
continue;
}
let (flags, positionals) = observed_shape(sc);
let entry = unknown.entry(name).or_default();
entry.0.extend(flags);
entry.1 = entry.1.max(positionals);
}
if unknown.is_empty() {
return Outcome::RecognizedButDenied {
names: recognized.into_iter().collect(),
};
}
let entries = unknown
.into_iter()
.map(|(name, (flags, max_positional))| GeneratedEntry {
name,
standalone: flags.into_iter().collect(),
max_positional,
level: DEFAULT_LEVEL.to_string(),
})
.collect();
Outcome::Generated {
entries,
also_recognized: recognized.into_iter().collect(),
}
}
fn command_basename(sc: &SimpleCmd) -> Option<String> {
let raw = sc.words.first()?.eval();
if raw.is_empty() {
return None;
}
Some(crate::parse::Token::from_raw(raw).command_name().to_string())
}
fn observed_shape(sc: &SimpleCmd) -> (Vec<String>, usize) {
let mut flags = Vec::new();
let mut positionals = 0;
for word in sc.words.iter().skip(1) {
let s = word.eval();
if s.starts_with('-') && s != "-" {
flags.push(s);
} else {
positionals += 1;
}
}
(flags, positionals)
}
fn collect_script<'a>(script: &'a Script, out: &mut Vec<&'a SimpleCmd>) {
for stmt in &script.0 {
for cmd in &stmt.pipeline.commands {
collect_cmd(cmd, out);
}
}
}
fn collect_cmd<'a>(cmd: &'a Cmd, out: &mut Vec<&'a SimpleCmd>) {
match cmd {
Cmd::Simple(sc) => {
out.push(sc);
for word in &sc.words {
collect_word(word, out);
}
}
Cmd::Subshell { body, .. } | Cmd::BraceGroup { body, .. } => collect_script(body, out),
Cmd::For { items, body, .. } => {
for word in items {
collect_word(word, out);
}
collect_script(body, out);
}
Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
collect_script(cond, out);
collect_script(body, out);
}
Cmd::If {
branches,
else_body,
..
} => {
for branch in branches {
collect_script(&branch.cond, out);
collect_script(&branch.body, out);
}
if let Some(body) = else_body {
collect_script(body, out);
}
}
Cmd::DoubleBracket { words, .. } => {
for word in words {
collect_word(word, out);
}
}
Cmd::Case { subject, arms, .. } => {
collect_word(subject, out);
for arm in arms {
collect_script(&arm.body, out);
}
}
Cmd::FunctionDef { body, .. } => collect_script(body, out),
}
}
fn collect_word<'a>(word: &'a Word, out: &mut Vec<&'a SimpleCmd>) {
for part in &word.0 {
match part {
WordPart::CmdSub(s) | WordPart::ProcSub(s) => collect_script(s, out),
WordPart::DQuote(w) => collect_word(w, out),
_ => {}
}
}
}
fn is_known(name: &str) -> bool {
known_names().contains(registry::canonical_name(name))
}
fn known_names() -> &'static BTreeSet<String> {
static KNOWN: OnceLock<BTreeSet<String>> = OnceLock::new();
KNOWN.get_or_init(|| {
let mut set: BTreeSet<String> = crate::docs::all_command_docs()
.into_iter()
.map(|d| d.name)
.collect();
for name in registry::toml_command_names() {
set.insert(name.to_string());
}
set
})
}
pub fn render_toml(entries: &[GeneratedEntry]) -> String {
let mut out = String::new();
for (i, entry) in entries.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("[[command]]\n");
out.push_str(&format!("name = {}\n", toml_str(&entry.name)));
if !entry.standalone.is_empty() {
let items: Vec<String> = entry.standalone.iter().map(|f| toml_str(f)).collect();
out.push_str(&format!("standalone = [{}]\n", items.join(", ")));
}
out.push_str(&format!("max_positional = {}\n", entry.max_positional));
out.push_str(&format!("level = {}\n", toml_str(&entry.level)));
}
out
}
fn toml_str(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c == '\u{7f}' => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
pub fn config_hash(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
pub fn merged_content(existing: &str, entries: &[GeneratedEntry]) -> String {
let block = render_toml(entries);
if existing.trim().is_empty() {
return block;
}
let mut content = existing.to_string();
if !content.ends_with('\n') {
content.push('\n');
}
content.push('\n');
content.push_str(&block);
content
}
pub fn pin_block(canonical_dir: &str, hash: &str) -> String {
format!(
"[[trusted]]\npath = {}\nsha256 = {}\n",
toml_str(canonical_dir),
toml_str(hash),
)
}
#[cfg(test)]
mod tests;