use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tracing::warn;
#[derive(Debug, Clone)]
pub struct AppRule {
pub name: String,
pub triggers: Vec<String>,
pub description: Option<String>,
pub body: String,
pub path: PathBuf,
}
#[derive(Debug, Default, Clone)]
pub struct AppRuleSet {
pub rules: Vec<AppRule>,
}
impl AppRuleSet {
pub fn load_dir(dir: &Path) -> Result<Self> {
if !dir.exists() {
return Ok(Self::default());
}
let mut paths: Vec<PathBuf> = Vec::new();
let entries =
std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))?;
for entry in entries {
let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
paths.push(path);
}
paths.sort();
let mut rules = Vec::new();
for path in paths {
match parse_file(&path) {
Ok(Some(rule)) => rules.push(rule),
Ok(None) => {
warn!(
path = %path.display(),
"app-rule file has no frontmatter, skipping"
);
}
Err(err) => {
warn!(
path = %path.display(),
error = %err,
"failed to parse app-rule file, skipping"
);
}
}
}
Ok(Self { rules })
}
pub fn match_instruction(&self, instruction: &str) -> Vec<&AppRule> {
let lower = instruction.to_lowercase();
let mut matched = Vec::new();
for rule in &self.rules {
for trigger in &rule.triggers {
let t = trigger.to_lowercase();
if t.is_empty() {
continue;
}
if lower.contains(&t) {
matched.push(rule);
break;
}
}
}
matched
}
}
fn parse_file(path: &Path) -> Result<Option<AppRule>> {
let text =
std::fs::read_to_string(path).with_context(|| format!("read file {}", path.display()))?;
let Some((frontmatter, body)) = split_frontmatter(&text) else {
return Ok(None);
};
let (name_opt, description, fm_triggers) = parse_frontmatter(frontmatter);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let name = name_opt.unwrap_or_else(|| stem.clone());
let mut triggers: Vec<String> = Vec::new();
let push_unique = |t: String, list: &mut Vec<String>| {
let t = t.trim().to_string();
if t.is_empty() {
return;
}
let lower = t.to_lowercase();
if !list.iter().any(|x| x.to_lowercase() == lower) {
list.push(t);
}
};
push_unique(name.clone(), &mut triggers);
if !stem.is_empty() {
push_unique(stem.clone(), &mut triggers);
}
for t in fm_triggers {
push_unique(t, &mut triggers);
}
for alias in canonical_aliases(&name)
.iter()
.chain(canonical_aliases(&stem))
{
push_unique(alias.to_string(), &mut triggers);
}
Ok(Some(AppRule {
name,
triggers,
description,
body: body.to_string(),
path: path.to_path_buf(),
}))
}
fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let rest = text.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
let mut search_from = 0usize;
while let Some(idx) = rest[search_from..].find("---") {
let abs = search_from + idx;
let line_start_ok = abs == 0 || rest.as_bytes()[abs - 1] == b'\n';
if !line_start_ok {
search_from = abs + 3;
continue;
}
let after = &rest[abs + 3..];
let body_after = if let Some(b) = after.strip_prefix('\n') {
b
} else if let Some(b) = after.strip_prefix("\r\n") {
b
} else if after.is_empty() {
after
} else {
search_from = abs + 3;
continue;
};
let frontmatter = &rest[..abs];
return Some((frontmatter, body_after));
}
None
}
fn parse_frontmatter(fm: &str) -> (Option<String>, Option<String>, Vec<String>) {
let mut name = None;
let mut description = None;
let mut triggers = Vec::new();
for line in fm.lines() {
let line = line.trim_end_matches('\r');
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let key = key.trim().to_lowercase();
let value = value.trim();
match key.as_str() {
"name" => name = Some(strip_quotes(value).to_string()),
"description" => description = Some(strip_quotes(value).to_string()),
"triggers" => {
triggers = parse_inline_list(value);
}
_ => {}
}
}
(name, description, triggers)
}
fn parse_inline_list(value: &str) -> Vec<String> {
let v = value.trim();
if let Some(inner) = v.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
inner
.split(',')
.map(|s| strip_quotes(s.trim()).to_string())
.filter(|s| !s.is_empty())
.collect()
} else if v.is_empty() {
Vec::new()
} else {
vec![strip_quotes(v).to_string()]
}
}
fn strip_quotes(s: &str) -> &str {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
|| (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
{
&s[1..s.len() - 1]
} else {
s
}
}
fn canonical_aliases(name: &str) -> &'static [&'static str] {
match name.to_lowercase().as_str() {
"wechat" => &["wechat", "微信", "weixin"],
"doubao" => &["doubao", "豆包"],
"douyin" => &["douyin", "抖音", "tiktok"],
"tonghuashun" => &["tonghuashun", "同花顺"],
_ => &[],
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, content).expect("write test file");
p
}
const WECHAT_MD: &str = "---\n\
name: wechat\n\
description: WeChat (微信) desktop client automation\n\
---\n\
\n\
# WeChat\n\
Body content here.\n";
#[test]
fn loads_wechat_rule_with_canonical_triggers() {
let dir = tempdir().expect("tempdir");
write(dir.path(), "wechat.md", WECHAT_MD);
let set = AppRuleSet::load_dir(dir.path()).expect("load");
assert_eq!(set.rules.len(), 1);
let r = &set.rules[0];
assert_eq!(r.name, "wechat");
let lower: Vec<String> = r.triggers.iter().map(|s| s.to_lowercase()).collect();
assert!(
lower.iter().any(|s| s == "wechat"),
"triggers: {:?}",
r.triggers
);
assert!(
lower.iter().any(|s| s == "微信"),
"triggers: {:?}",
r.triggers
);
assert!(
lower.iter().any(|s| s == "weixin"),
"triggers: {:?}",
r.triggers
);
assert!(r.body.contains("Body content here"));
assert_eq!(
r.description.as_deref(),
Some("WeChat (微信) desktop client automation")
);
}
#[test]
fn skips_files_without_frontmatter() {
let dir = tempdir().expect("tempdir");
write(dir.path(), "wechat.md", WECHAT_MD);
write(dir.path(), "broken.md", "no frontmatter here\njust text\n");
let set = AppRuleSet::load_dir(dir.path()).expect("load");
assert_eq!(set.rules.len(), 1);
assert_eq!(set.rules[0].name, "wechat");
}
#[test]
fn match_english_substring() {
let dir = tempdir().expect("tempdir");
write(dir.path(), "wechat.md", WECHAT_MD);
let set = AppRuleSet::load_dir(dir.path()).expect("load");
let m = set.match_instruction("send a wechat message");
assert_eq!(m.len(), 1);
assert_eq!(m[0].name, "wechat");
}
#[test]
fn match_cjk_substring() {
let dir = tempdir().expect("tempdir");
write(dir.path(), "wechat.md", WECHAT_MD);
let set = AppRuleSet::load_dir(dir.path()).expect("load");
let m = set.match_instruction("微信群里看看");
assert_eq!(m.len(), 1);
assert_eq!(m[0].name, "wechat");
}
#[test]
fn no_match_returns_empty() {
let dir = tempdir().expect("tempdir");
write(dir.path(), "wechat.md", WECHAT_MD);
let set = AppRuleSet::load_dir(dir.path()).expect("load");
let m = set.match_instruction("buy stocks");
assert!(m.is_empty());
}
#[test]
fn empty_dir_returns_empty_set() {
let dir = tempdir().expect("tempdir");
let set = AppRuleSet::load_dir(dir.path()).expect("load");
assert!(set.rules.is_empty());
}
#[test]
fn explicit_triggers_field_in_frontmatter() {
let dir = tempdir().expect("tempdir");
let content = "---\n\
name: myapp\n\
triggers: [foo, bar, \"baz qux\"]\n\
---\n\
body\n";
write(dir.path(), "myapp.md", content);
let set = AppRuleSet::load_dir(dir.path()).expect("load");
assert_eq!(set.rules.len(), 1);
let r = &set.rules[0];
let lower: Vec<String> = r.triggers.iter().map(|s| s.to_lowercase()).collect();
assert!(lower.contains(&"foo".to_string()));
assert!(lower.contains(&"bar".to_string()));
assert!(lower.contains(&"baz qux".to_string()));
}
}