use std::io::Write;
use std::path::Path;
use colored::Colorize;
use comfy_table::presets::NOTHING;
use comfy_table::{Cell, Table};
use serde::{Deserialize, Serialize};
use crate::cli::config::scrap_config::ScrapConfig;
use crate::cli::json::scrap::{LinkKindJson, ScrapRefJson};
use crate::cli::path_resolver::PathResolver;
use crate::cli::scrap_arg::scrap_key;
use crate::error::ScrapsResult;
use crate::ir::loader;
use crate::usecase::scrap::lookup_links::usecase::LookupScrapLinksUsecase;
#[derive(Debug, Serialize, Deserialize)]
struct LinkRefJson {
kind: LinkKindJson,
#[serde(flatten)]
scrap: ScrapRefJson,
heading: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct LinksResponse {
results: Vec<LinkRefJson>,
count: usize,
}
pub fn run(
key: &str,
ctx: Option<&str>,
json: bool,
project_path: Option<&Path>,
writer: &mut impl Write,
) -> ScrapsResult<()> {
let path_resolver = PathResolver::new(project_path)?;
let config = ScrapConfig::from_path(project_path)?;
let scraps_dir_path = path_resolver.scraps_dir();
let exclude_dirs = vec![
path_resolver.static_dir(),
path_resolver.output_dir(&config),
];
let scraps = loader::load_scraps(&scraps_dir_path, &exclude_dirs)?;
let target_key = scrap_key(key, ctx);
let usecase = LookupScrapLinksUsecase::new();
let results = usecase.execute(&scraps, &target_key)?;
let refs: Vec<LinkRefJson> = results
.into_iter()
.map(|r| LinkRefJson {
kind: r.kind.into(),
scrap: (&r.scrap).into(),
heading: r.heading,
})
.collect();
if json {
let count = refs.len();
let response = LinksResponse {
results: refs,
count,
};
writeln!(writer, "{}", serde_json::to_string(&response)?)?;
} else {
if refs.is_empty() {
return Ok(());
}
let mut table = Table::new();
table.load_style(NOTHING);
table.set_header(vec![
Cell::new("Kind".bold()),
Cell::new("Key".bold()),
Cell::new("Title".bold()),
Cell::new("Context".bold()),
Cell::new("Heading".bold()),
]);
for r in &refs {
let kind_str = r.kind.as_str();
table.add_row(vec![
Cell::new(kind_str),
Cell::new(&r.scrap.key),
Cell::new(&r.scrap.title),
Cell::new(r.scrap.ctx.as_deref().unwrap_or("")),
Cell::new(r.heading.as_deref().unwrap_or("")),
]);
}
writeln!(writer, "{table}")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{TempScrapProject, temp_scrap_project};
use rstest::rstest;
#[rstest]
fn run_text_outputs_links(#[from(temp_scrap_project)] project: TempScrapProject) {
project
.add_config(b"")
.add_scrap("rust.md", b"# Rust\n\nLinks to [[cargo]] and [[clippy]].")
.add_scrap("cargo.md", b"# Cargo")
.add_scrap("clippy.md", b"# Clippy");
let mut buf = Vec::new();
run(
"rust",
None,
false,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("cargo"));
assert!(output.contains("clippy"));
}
#[rstest]
fn run_json_outputs_links(#[from(temp_scrap_project)] project: TempScrapProject) {
project
.add_config(b"")
.add_scrap("rust.md", b"# Rust\n\nLinks to [[cargo]] and [[clippy]].")
.add_scrap("cargo.md", b"# Cargo")
.add_scrap("clippy.md", b"# Clippy");
let mut buf = Vec::new();
run(
"rust",
None,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
let response: LinksResponse = serde_json::from_str(output.trim()).unwrap();
assert_eq!(response.count, 2);
assert_eq!(response.results.len(), 2);
let titles: Vec<&str> = response
.results
.iter()
.map(|r| r.scrap.title.as_str())
.collect();
assert!(titles.contains(&"cargo"));
assert!(titles.contains(&"clippy"));
assert!(
response
.results
.iter()
.all(|r| r.kind == LinkKindJson::Link)
);
assert!(response.results.iter().all(|r| r.heading.is_none()));
}
#[rstest]
fn run_json_outputs_empty_for_no_links(#[from(temp_scrap_project)] project: TempScrapProject) {
project
.add_config(b"")
.add_scrap("rust.md", b"# Rust\n\nNo links here.");
let mut buf = Vec::new();
run(
"rust",
None,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
let response: LinksResponse = serde_json::from_str(output.trim()).unwrap();
assert_eq!(response.count, 0);
assert!(response.results.is_empty());
}
#[rstest]
fn run_with_ctx_resolves_target(#[from(temp_scrap_project)] project: TempScrapProject) {
project
.add_config(b"")
.add_scrap("Backend/Auth.md", b"# Auth\n\nLinks to [[Token]].")
.add_scrap("Frontend/Auth.md", b"# Auth\n\nLinks to [[Form]].")
.add_scrap("Token.md", b"# Token")
.add_scrap("Form.md", b"# Form");
let mut buf = Vec::new();
run(
"Auth",
Some("Backend"),
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
let response: LinksResponse = serde_json::from_str(output.trim()).unwrap();
assert_eq!(response.count, 1);
assert_eq!(response.results[0].scrap.title, "Token");
}
#[rstest]
fn run_errors_when_ctx_is_omitted_for_ctx_scoped_scrap(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
project
.add_config(b"")
.add_scrap("Backend/Auth.md", b"# Auth\n\nLinks to [[Token]].")
.add_scrap("Token.md", b"# Token");
let mut buf = Vec::new();
let result = run(
"Auth",
None,
false,
Some(project.project_root.as_path()),
&mut buf,
);
assert!(result.is_err());
}
#[rstest]
fn run_errors_on_missing_title(#[from(temp_scrap_project)] project: TempScrapProject) {
project.add_config(b"").add_scrap("rust.md", b"# Rust");
let mut buf = Vec::new();
let result = run(
"nonexistent",
None,
false,
Some(project.project_root.as_path()),
&mut buf,
);
assert!(result.is_err());
}
#[rstest]
fn run_fails_without_config(#[from(temp_scrap_project)] project: TempScrapProject) {
let mut buf = Vec::new();
let result = run(
"rust",
None,
false,
Some(project.project_root.as_path()),
&mut buf,
);
assert!(result.is_err());
}
#[rstest]
fn run_json_outputs_heading_qualified_occurrences(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
project
.add_config(b"")
.add_scrap(
"rust.md",
b"# Rust\n\nSee [[Target#Install]] and [[Target#Usage]].",
)
.add_scrap("Target.md", b"# Target");
let mut buf = Vec::new();
run(
"rust",
None,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
let response: LinksResponse = serde_json::from_str(output.trim()).unwrap();
assert_eq!(response.count, 2);
let headings: Vec<Option<&str>> = response
.results
.iter()
.map(|r| r.heading.as_deref())
.collect();
assert!(headings.contains(&Some("Install")));
assert!(headings.contains(&Some("Usage")));
}
#[rstest]
fn run_json_includes_embed_kind(#[from(temp_scrap_project)] project: TempScrapProject) {
project
.add_config(b"")
.add_scrap("rust.md", b"# Rust\n\nEmbed ![[Guide]] and link [[Other]].")
.add_scrap("Guide.md", b"# Guide")
.add_scrap("Other.md", b"# Other");
let mut buf = Vec::new();
run(
"rust",
None,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
let response: LinksResponse = serde_json::from_str(output.trim()).unwrap();
assert_eq!(response.count, 2);
let kinds: Vec<LinkKindJson> = response.results.iter().map(|r| r.kind).collect();
assert!(kinds.contains(&LinkKindJson::Embed));
assert!(kinds.contains(&LinkKindJson::Link));
}
#[rstest]
fn run_json_excludes_explicit_tags(#[from(temp_scrap_project)] project: TempScrapProject) {
project
.add_config(b"")
.add_scrap("rust.md", b"# Rust\n\nLink [[Target]] and tag #[[ai]].")
.add_scrap("Target.md", b"# Target");
let mut buf = Vec::new();
run(
"rust",
None,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
let response: LinksResponse = serde_json::from_str(output.trim()).unwrap();
assert_eq!(response.count, 1);
assert_eq!(response.results[0].scrap.title, "Target");
}
#[rstest]
fn run_json_names_a_source_by_its_kind(#[from(temp_scrap_project)] project: TempScrapProject) {
project
.add_config(b"")
.add_scrap(
"OKF.md",
b"---\nsources:\n - resource: /Blog/Intro.md\n---\n\n# OKF\n",
)
.add_scrap("Blog/Intro.md", b"# Intro");
let mut buf = Vec::new();
run(
"OKF",
None,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
let response: LinksResponse = serde_json::from_str(output.trim()).unwrap();
assert_eq!(response.count, 1);
assert_eq!(response.results[0].kind, LinkKindJson::Source);
assert_eq!(response.results[0].scrap.key, "Blog/Intro");
}
#[rstest]
fn run_json_serializes_kind_as_lowercase(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
project
.add_config(b"")
.add_scrap("rust.md", b"# Rust\n\n[[Target]] and ![[Guide]].")
.add_scrap("Target.md", b"# Target")
.add_scrap("Guide.md", b"# Guide");
let mut buf = Vec::new();
run(
"rust",
None,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("\"kind\":\"link\""));
assert!(output.contains("\"kind\":\"embed\""));
}
#[rstest]
fn run_json_names_each_link_by_key_and_title(
#[from(temp_scrap_project)] project: TempScrapProject,
) {
project
.add_config(b"")
.add_scrap(
"tables/orders.md",
b"---\ntitle: Customer Orders\n---\n\nOne row per order.\n",
)
.add_scrap(
"metrics/revenue.md",
b"See [[tables/orders]] and [[tables/orders|the orders table]].\n",
);
let links = |key: &str, ctx: Option<&str>| {
let mut buf = Vec::new();
run(
key,
ctx,
true,
Some(project.project_root.as_path()),
&mut buf,
)
.unwrap();
String::from_utf8(buf).unwrap()
};
let by_key = links("metrics/revenue", None);
let response: LinksResponse = serde_json::from_str(by_key.trim()).unwrap();
assert_eq!(response.count, 2);
for link in &response.results {
assert_eq!(link.scrap.key, "tables/orders");
assert_eq!(link.scrap.title, "Customer Orders");
assert_eq!(link.scrap.ctx.as_deref(), Some("tables"));
}
assert_eq!(links("revenue", Some("metrics")), by_key);
}
}