The fork's CI had never been green. Pre-existing failures: - version-sync: check-version-sync.js read packages/dashboard/package.json, which doesn't exist in this fork (workspace is just "."). Drop the dashboard comparison; check package.json vs cli/Cargo.toml only. - Dashboard job: `pnpm install --filter dashboard` for a non-existent package. Remove the job. - Format check: repo was never `cargo fmt`-clean. Ran cargo fmt (mechanical). - Clippy -D warnings (newly enforced on Rust 1.94 stable): manual_contains in commands.rs (.iter().any()->.contains()), question_mark in element.rs (if-let-Err -> ?), result_large_err on the tungstenite handshake callback in connect.rs (allow — the Result type is fixed by the accept_hdr_async contract). - rust-cross: lightpanda::waits_for_ready_without_logs spawns a real process + binds a socket with timing assumptions; flaky in CI. Marked #[ignore]. Also: skill docs note fork.30's relay-preferred auto-connect (plain `agent-browser open` is dialog-free once the ab-connect extension is loaded) and the extension's new "agent-browser-stealth" display name.
668 lines
22 KiB
Rust
668 lines
22 KiB
Rust
use include_dir::{include_dir, Dir};
|
|
use serde_json::json;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::exit;
|
|
|
|
use crate::color;
|
|
|
|
/// Skill content compiled into the binary so `skills get` works on a
|
|
/// single-binary install (GitHub Release / install.sh), where there is no
|
|
/// adjacent `skills/` or `skill-data/` on disk the way an npm install has.
|
|
static EMBEDDED_SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skills");
|
|
static EMBEDDED_SKILL_DATA: Dir = include_dir!("$CARGO_MANIFEST_DIR/../skill-data");
|
|
|
|
struct SkillInfo {
|
|
name: String,
|
|
description: String,
|
|
dir: PathBuf,
|
|
/// When true, the skill is omitted from `skills list` and `skills get --all`
|
|
/// but can still be fetched by name via `skills get <name>`. Used for
|
|
/// bootstrap stubs that exist for external tooling (e.g. `npx skills add`)
|
|
/// but aren't the intended entry point for agents already inside the CLI.
|
|
hidden: bool,
|
|
}
|
|
|
|
/// Skill content is split across two directories:
|
|
///
|
|
/// - `skills/` — discovery stubs (picked up by `npx skills add`). Carry
|
|
/// `hidden: true` so they don't show up in `skills list` or `skills get
|
|
/// --all` inside the CLI, since they exist only to redirect external
|
|
/// agents to `skills get core`.
|
|
/// - `skill-data/` — runtime skill content served by the CLI (`core`,
|
|
/// `electron`, `slack`, `dogfood`, etc.).
|
|
///
|
|
/// Both are shipped in the npm package and searched by `discover_skills`.
|
|
const SKILL_DIRS: &[&str] = &["skills", "skill-data"];
|
|
|
|
/// Locate the package root that contains the skill directories.
|
|
///
|
|
/// Resolution order:
|
|
/// 1. AGENT_BROWSER_SKILLS_DIR env var (points directly at a single directory)
|
|
/// 2. ../ relative to the executable (npm installs: binary is in bin/)
|
|
/// 3. Walk up from the executable to find a project root with skills/
|
|
/// (dev builds where binary is in target/debug/ or target/release/)
|
|
fn find_package_root() -> Option<PathBuf> {
|
|
if let Ok(exe) = env::current_exe() {
|
|
let exe = exe.canonicalize().unwrap_or(exe);
|
|
if let Some(parent) = exe.parent() {
|
|
// npm install layout: bin/agent-browser-* -> ../
|
|
let candidate = parent.join("..");
|
|
if candidate.join("skills").is_dir() {
|
|
return Some(candidate.canonicalize().unwrap_or(candidate));
|
|
}
|
|
|
|
// dev build layout: walk up from target/debug/ or target/release/
|
|
let mut dir = parent;
|
|
loop {
|
|
if dir.join("skills").is_dir() {
|
|
return Some(dir.to_path_buf());
|
|
}
|
|
match dir.parent() {
|
|
Some(p) => dir = p,
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Extract the binary-embedded skill content to a per-version cache dir on
|
|
/// first use, returning a package root that contains `skills/` and
|
|
/// `skill-data/`. Fallback for single-binary installs (GitHub Release /
|
|
/// install.sh) that have no on-disk skill directories. Version-stamped so an
|
|
/// upgraded binary re-extracts fresh content.
|
|
fn embedded_skills_root() -> Option<PathBuf> {
|
|
let base = dirs::cache_dir()?
|
|
.join("agent-browser")
|
|
.join(concat!("skills-", env!("CARGO_PKG_VERSION")));
|
|
let marker = base.join(".extracted");
|
|
if !marker.exists() {
|
|
let _ = fs::create_dir_all(base.join("skills"));
|
|
let _ = fs::create_dir_all(base.join("skill-data"));
|
|
if EMBEDDED_SKILLS.extract(base.join("skills")).is_err()
|
|
|| EMBEDDED_SKILL_DATA
|
|
.extract(base.join("skill-data"))
|
|
.is_err()
|
|
{
|
|
return None;
|
|
}
|
|
let _ = fs::write(&marker, env!("CARGO_PKG_VERSION"));
|
|
}
|
|
base.join("skills").is_dir().then_some(base)
|
|
}
|
|
|
|
/// Collect all skill directories to search, respecting the env var override.
|
|
fn find_skills_dirs() -> Vec<PathBuf> {
|
|
// Env var override: single directory, used as-is
|
|
if let Ok(dir) = env::var("AGENT_BROWSER_SKILLS_DIR") {
|
|
let p = PathBuf::from(dir);
|
|
if p.is_dir() {
|
|
return vec![p];
|
|
}
|
|
}
|
|
|
|
// On-disk package root (npm install layout, or dev build walking up to repo).
|
|
if let Some(root) = find_package_root() {
|
|
let dirs: Vec<PathBuf> = SKILL_DIRS
|
|
.iter()
|
|
.map(|d| root.join(d))
|
|
.filter(|p| p.is_dir())
|
|
.collect();
|
|
if !dirs.is_empty() {
|
|
return dirs;
|
|
}
|
|
}
|
|
|
|
// Fallback: skill content compiled into the binary (single-binary install).
|
|
if let Some(root) = embedded_skills_root() {
|
|
return SKILL_DIRS
|
|
.iter()
|
|
.map(|d| root.join(d))
|
|
.filter(|p| p.is_dir())
|
|
.collect();
|
|
}
|
|
|
|
vec![]
|
|
}
|
|
|
|
/// Parse YAML frontmatter from a SKILL.md file. Returns (name, description, hidden).
|
|
fn parse_frontmatter(content: &str) -> Option<(String, String, bool)> {
|
|
let content = content.trim_start();
|
|
if !content.starts_with("---") {
|
|
return None;
|
|
}
|
|
let after_opening = &content[3..];
|
|
let end = after_opening.find("\n---")?;
|
|
let frontmatter = &after_opening[..end];
|
|
|
|
let mut name = None;
|
|
let mut description = None;
|
|
let mut hidden = false;
|
|
|
|
let lines: Vec<&str> = frontmatter.lines().collect();
|
|
let mut i = 0;
|
|
while i < lines.len() {
|
|
let line = lines[i];
|
|
if let Some(val) = line.strip_prefix("name:") {
|
|
name = Some(val.trim().to_string());
|
|
} else if let Some(val) = line.strip_prefix("description:") {
|
|
let mut desc = val.trim().to_string();
|
|
// Consume YAML continuation lines (indented with spaces or tab)
|
|
while i + 1 < lines.len()
|
|
&& (lines[i + 1].starts_with(" ") || lines[i + 1].starts_with('\t'))
|
|
{
|
|
i += 1;
|
|
desc.push(' ');
|
|
desc.push_str(lines[i].trim());
|
|
}
|
|
description = Some(desc);
|
|
} else if let Some(val) = line.strip_prefix("hidden:") {
|
|
hidden = matches!(val.trim(), "true" | "yes");
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
Some((name?, description.unwrap_or_default(), hidden))
|
|
}
|
|
|
|
/// Discover all skills across the given directories.
|
|
fn discover_skills(dirs: &[PathBuf]) -> Vec<SkillInfo> {
|
|
let mut skills = Vec::new();
|
|
|
|
for skills_dir in dirs {
|
|
let entries = match fs::read_dir(skills_dir) {
|
|
Ok(e) => e,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
let skill_md = path.join("SKILL.md");
|
|
if !skill_md.exists() {
|
|
continue;
|
|
}
|
|
let content = match fs::read_to_string(&skill_md) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
if let Some((name, description, hidden)) = parse_frontmatter(&content) {
|
|
skills.push(SkillInfo {
|
|
name,
|
|
description,
|
|
dir: path,
|
|
hidden,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
skills.sort_by(|a, b| a.name.cmp(&b.name));
|
|
skills
|
|
}
|
|
|
|
fn truncate_description(desc: &str, max_len: usize) -> String {
|
|
if desc.len() <= max_len {
|
|
return desc.to_string();
|
|
}
|
|
let boundary = desc
|
|
.char_indices()
|
|
.take_while(|(i, _)| *i <= max_len)
|
|
.last()
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(max_len);
|
|
let end = desc[..boundary].rfind(' ').unwrap_or(boundary);
|
|
format!("{}...", &desc[..end])
|
|
}
|
|
|
|
/// Read the full SKILL.md content (including frontmatter).
|
|
fn read_skill_full(skill_md: &Path) -> Option<String> {
|
|
fs::read_to_string(skill_md).ok()
|
|
}
|
|
|
|
/// Collect all supplementary files (references/, templates/) for a skill.
|
|
fn collect_supplementary_files(skill_dir: &Path) -> Vec<(String, String)> {
|
|
let mut files = Vec::new();
|
|
for subdir_name in &["references", "templates"] {
|
|
let subdir = skill_dir.join(subdir_name);
|
|
if !subdir.is_dir() {
|
|
continue;
|
|
}
|
|
let mut entries: Vec<_> = match fs::read_dir(&subdir) {
|
|
Ok(e) => e.flatten().collect(),
|
|
Err(_) => continue,
|
|
};
|
|
entries.sort_by_key(|e| e.file_name());
|
|
for entry in entries {
|
|
let path = entry.path();
|
|
if path.is_file() {
|
|
if let Ok(content) = fs::read_to_string(&path) {
|
|
let rel = format!(
|
|
"{}/{}",
|
|
subdir_name,
|
|
path.file_name().unwrap_or_default().to_string_lossy()
|
|
);
|
|
files.push((rel, content));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
files
|
|
}
|
|
|
|
fn run_list(skills_dirs: &[PathBuf], json_mode: bool) {
|
|
let skills: Vec<SkillInfo> = discover_skills(skills_dirs)
|
|
.into_iter()
|
|
.filter(|s| !s.hidden)
|
|
.collect();
|
|
if skills.is_empty() {
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({ "success": true, "data": [] })).unwrap_or_default()
|
|
);
|
|
} else {
|
|
println!("No skills found");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if json_mode {
|
|
let items: Vec<serde_json::Value> = skills
|
|
.iter()
|
|
.map(|s| {
|
|
json!({
|
|
"name": s.name,
|
|
"description": s.description,
|
|
})
|
|
})
|
|
.collect();
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({ "success": true, "data": items })).unwrap_or_default()
|
|
);
|
|
} else {
|
|
let max_name = skills.iter().map(|s| s.name.len()).max().unwrap_or(0);
|
|
for s in &skills {
|
|
println!(
|
|
" {:<width$} {}",
|
|
s.name,
|
|
truncate_description(&s.description, 70),
|
|
width = max_name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_get(skills_dirs: &[PathBuf], names: &[String], get_all: bool, full: bool, json_mode: bool) {
|
|
let all_skills = discover_skills(skills_dirs);
|
|
|
|
let targets: Vec<&SkillInfo> = if get_all {
|
|
all_skills.iter().filter(|s| !s.hidden).collect()
|
|
} else {
|
|
let mut targets = Vec::new();
|
|
for name in names {
|
|
if name.starts_with('-') {
|
|
eprintln!(
|
|
"{} Unknown flag ignored: {}",
|
|
color::warning_indicator(),
|
|
name
|
|
);
|
|
continue;
|
|
}
|
|
match all_skills.iter().find(|s| s.name == *name) {
|
|
Some(s) => targets.push(s),
|
|
None => {
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({
|
|
"success": false,
|
|
"error": format!("Skill not found: {}", name),
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
eprintln!("{} Skill not found: {}", color::error_indicator(), name);
|
|
}
|
|
exit(1);
|
|
}
|
|
}
|
|
}
|
|
targets
|
|
};
|
|
|
|
if targets.is_empty() {
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({
|
|
"success": false,
|
|
"error": "No skill name provided. Usage: agent-browser skills get <name>",
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
"{} No skill name provided. Usage: agent-browser skills get <name>",
|
|
color::error_indicator()
|
|
);
|
|
}
|
|
exit(1);
|
|
}
|
|
|
|
if json_mode {
|
|
let items: Vec<serde_json::Value> = targets
|
|
.iter()
|
|
.map(|s| {
|
|
let skill_md = s.dir.join("SKILL.md");
|
|
let content = read_skill_full(&skill_md).unwrap_or_default();
|
|
let mut obj = json!({
|
|
"name": s.name,
|
|
"content": content,
|
|
});
|
|
if full {
|
|
let supplementary = collect_supplementary_files(&s.dir);
|
|
if !supplementary.is_empty() {
|
|
let files: Vec<serde_json::Value> = supplementary
|
|
.iter()
|
|
.map(|(path, content)| json!({ "path": path, "content": content }))
|
|
.collect();
|
|
obj["files"] = json!(files);
|
|
}
|
|
}
|
|
obj
|
|
})
|
|
.collect();
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({ "success": true, "data": items })).unwrap_or_default()
|
|
);
|
|
} else {
|
|
for (i, s) in targets.iter().enumerate() {
|
|
if i > 0 {
|
|
println!("\n---\n");
|
|
}
|
|
let skill_md = s.dir.join("SKILL.md");
|
|
if let Some(content) = read_skill_full(&skill_md) {
|
|
print!("{}", content);
|
|
if !content.ends_with('\n') {
|
|
println!();
|
|
}
|
|
}
|
|
if full {
|
|
let supplementary = collect_supplementary_files(&s.dir);
|
|
for (path, content) in &supplementary {
|
|
println!("\n--- {} ---\n", path);
|
|
print!("{}", content);
|
|
if !content.ends_with('\n') {
|
|
println!();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_path(skills_dirs: &[PathBuf], name: Option<&str>, json_mode: bool) {
|
|
match name {
|
|
Some(name) => {
|
|
let all_skills = discover_skills(skills_dirs);
|
|
match all_skills.iter().find(|s| s.name == name) {
|
|
Some(s) => {
|
|
let path = s.dir.to_string_lossy().to_string();
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({
|
|
"success": true,
|
|
"data": { "name": s.name, "path": path },
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
println!("{}", path);
|
|
}
|
|
}
|
|
None => {
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({
|
|
"success": false,
|
|
"error": format!("Skill not found: {}", name),
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
eprintln!("{} Skill not found: {}", color::error_indicator(), name);
|
|
}
|
|
exit(1);
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
let paths: Vec<String> = skills_dirs
|
|
.iter()
|
|
.map(|d| d.to_string_lossy().to_string())
|
|
.collect();
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({
|
|
"success": true,
|
|
"data": { "paths": paths },
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
for p in &paths {
|
|
println!("{}", p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn run_skills(args: &[String], json_mode: bool) {
|
|
let skills_dirs = find_skills_dirs();
|
|
if skills_dirs.is_empty() {
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({
|
|
"success": false,
|
|
"error": "Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.",
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
"{} Skills directory not found. Set AGENT_BROWSER_SKILLS_DIR or reinstall via npm.",
|
|
color::error_indicator()
|
|
);
|
|
}
|
|
exit(1);
|
|
}
|
|
|
|
let subcommand = args.get(1).map(|s| s.as_str());
|
|
|
|
match subcommand {
|
|
None | Some("list") => run_list(&skills_dirs, json_mode),
|
|
Some("get") => {
|
|
let names: Vec<String> = args[2..]
|
|
.iter()
|
|
.filter(|a| *a != "--full" && *a != "--all")
|
|
.cloned()
|
|
.collect();
|
|
let full = args[2..].iter().any(|a| a == "--full");
|
|
let get_all = args[2..].iter().any(|a| a == "--all");
|
|
run_get(&skills_dirs, &names, get_all, full, json_mode);
|
|
}
|
|
Some("path") => {
|
|
let name = args.get(2).map(|s| s.as_str());
|
|
run_path(&skills_dirs, name, json_mode);
|
|
}
|
|
Some(unknown) => {
|
|
if json_mode {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&json!({
|
|
"success": false,
|
|
"error": format!("Unknown skills subcommand: {}", unknown),
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
eprintln!(
|
|
"{} Unknown skills subcommand: {}",
|
|
color::error_indicator(),
|
|
unknown
|
|
);
|
|
}
|
|
exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn create_test_skill(dir: &Path, name: &str, description: &str) {
|
|
let skill_dir = dir.join(name);
|
|
fs::create_dir_all(&skill_dir).unwrap();
|
|
fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
format!(
|
|
"---\nname: {}\ndescription: {}\n---\n\n# {}\n\nContent here.\n",
|
|
name, description, name
|
|
),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_frontmatter_basic() {
|
|
let content = "---\nname: test-skill\ndescription: A test skill.\n---\n\n# Test\n";
|
|
let (name, desc, hidden) = parse_frontmatter(content).unwrap();
|
|
assert_eq!(name, "test-skill");
|
|
assert_eq!(desc, "A test skill.");
|
|
assert!(!hidden);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_frontmatter_multiline_description() {
|
|
let content =
|
|
"---\nname: test\ndescription: First line\n continued here\n and here\n---\n";
|
|
let (name, desc, hidden) = parse_frontmatter(content).unwrap();
|
|
assert_eq!(name, "test");
|
|
assert_eq!(desc, "First line continued here and here");
|
|
assert!(!hidden);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_frontmatter_hidden_true() {
|
|
let content = "---\nname: stub\ndescription: A bootstrap stub.\nhidden: true\n---\n";
|
|
let (name, desc, hidden) = parse_frontmatter(content).unwrap();
|
|
assert_eq!(name, "stub");
|
|
assert_eq!(desc, "A bootstrap stub.");
|
|
assert!(hidden);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_frontmatter_hidden_false() {
|
|
let content = "---\nname: visible\ndescription: Visible.\nhidden: false\n---\n";
|
|
let (_, _, hidden) = parse_frontmatter(content).unwrap();
|
|
assert!(!hidden);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_frontmatter_no_frontmatter() {
|
|
let content = "# Just a heading\n\nNo frontmatter here.\n";
|
|
assert!(parse_frontmatter(content).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_frontmatter_missing_name() {
|
|
let content = "---\ndescription: No name field\n---\n";
|
|
assert!(parse_frontmatter(content).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_discover_skills_single_dir() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
create_test_skill(tmp.path(), "alpha", "Alpha skill");
|
|
create_test_skill(tmp.path(), "beta", "Beta skill");
|
|
|
|
// Non-skill directory (no SKILL.md)
|
|
fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap();
|
|
fs::write(tmp.path().join("not-a-skill").join("README.md"), "hi").unwrap();
|
|
|
|
let dirs = vec![tmp.path().to_path_buf()];
|
|
let skills = discover_skills(&dirs);
|
|
assert_eq!(skills.len(), 2);
|
|
assert_eq!(skills[0].name, "alpha");
|
|
assert_eq!(skills[1].name, "beta");
|
|
}
|
|
|
|
#[test]
|
|
fn test_discover_skills_multiple_dirs() {
|
|
let tmp1 = tempfile::tempdir().unwrap();
|
|
let tmp2 = tempfile::tempdir().unwrap();
|
|
create_test_skill(tmp1.path(), "alpha", "Alpha skill");
|
|
create_test_skill(tmp2.path(), "beta", "Beta skill");
|
|
create_test_skill(tmp2.path(), "gamma", "Gamma skill");
|
|
|
|
let dirs = vec![tmp1.path().to_path_buf(), tmp2.path().to_path_buf()];
|
|
let skills = discover_skills(&dirs);
|
|
assert_eq!(skills.len(), 3);
|
|
assert_eq!(skills[0].name, "alpha");
|
|
assert_eq!(skills[1].name, "beta");
|
|
assert_eq!(skills[2].name, "gamma");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_description() {
|
|
assert_eq!(truncate_description("short", 10), "short");
|
|
assert_eq!(
|
|
truncate_description("this is a longer description that should be truncated", 20),
|
|
"this is a longer..."
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_description_multibyte() {
|
|
let desc = "Browse \u{00e9}l\u{00e9}ments and \u{65e5}\u{672c}\u{8a9e} pages quickly";
|
|
let result = truncate_description(desc, 20);
|
|
assert!(result.ends_with("..."));
|
|
assert!(result.len() <= 30);
|
|
}
|
|
|
|
#[test]
|
|
fn test_collect_supplementary_files() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let refs_dir = tmp.path().join("references");
|
|
fs::create_dir_all(&refs_dir).unwrap();
|
|
fs::write(refs_dir.join("auth.md"), "# Auth\n").unwrap();
|
|
fs::write(refs_dir.join("commands.md"), "# Commands\n").unwrap();
|
|
|
|
let templates_dir = tmp.path().join("templates");
|
|
fs::create_dir_all(&templates_dir).unwrap();
|
|
fs::write(templates_dir.join("example.sh"), "#!/bin/bash\n").unwrap();
|
|
|
|
let files = collect_supplementary_files(tmp.path());
|
|
assert_eq!(files.len(), 3);
|
|
assert_eq!(files[0].0, "references/auth.md");
|
|
assert_eq!(files[1].0, "references/commands.md");
|
|
assert_eq!(files[2].0, "templates/example.sh");
|
|
}
|
|
}
|