1use cssparser::{Parser as CssParser, ParserInput as CssParserInput, Token as CssToken};
4use jsonc_parser::{ParseOptions as JsoncParseOptions, parse_to_serde_value};
5use projectatlas_core::language::{StructuralSummaryOwner, language_capability};
6use projectatlas_symbols::{MarkdownFacts, extract_markdown_facts};
7use scraper::{Html, Selector};
8use serde_json::Value as JsonValue;
9use std::collections::BTreeSet;
10use toml::Value as TomlValue;
11use yaml_rust2::{Yaml, YamlLoader};
12
13const LIST_LIMIT: usize = 4;
15
16const LABEL_LIMIT: usize = 80;
18
19pub(crate) fn structural_summary_for_path(
21 path: &str,
22 language: Option<&str>,
23 content: &str,
24) -> Option<String> {
25 let owner = structural_summary_owner(path, language)?;
26 match owner {
27 StructuralSummaryOwner::Markdown => markdown_summary(content),
28 StructuralSummaryOwner::Json => json_summary(path, content),
29 StructuralSummaryOwner::Yaml => yaml_summary(path, content),
30 StructuralSummaryOwner::Toml => toml_summary(path, content),
31 StructuralSummaryOwner::Xml => xml_summary(content),
32 StructuralSummaryOwner::Css => css_summary(content),
33 StructuralSummaryOwner::Html => html_summary(content),
34 StructuralSummaryOwner::Toon => toon_summary(content),
35 StructuralSummaryOwner::PowerShell => powershell_summary(content),
36 StructuralSummaryOwner::ConfigText => config_text_summary(path, content),
37 }
38}
39
40pub(crate) fn is_structural_summary_candidate(path: &str, language: Option<&str>) -> bool {
42 structural_summary_owner(path, language).is_some()
43}
44
45fn structural_summary_owner(path: &str, language: Option<&str>) -> Option<StructuralSummaryOwner> {
47 match language {
48 Some(language) => {
49 language_capability(language).and_then(|capability| capability.structural_summary)
50 }
51 None => has_extension(path, "toon").then_some(StructuralSummaryOwner::Toon),
52 }
53}
54
55fn has_extension(path: &str, extension: &str) -> bool {
57 path.rsplit(['/', '\\'])
58 .next()
59 .and_then(|file_name| file_name.rsplit_once('.').map(|(_stem, ext)| ext))
60 .is_some_and(|ext| ext.eq_ignore_ascii_case(extension))
61}
62
63pub(crate) fn is_scanner_fallback_summary(summary: &str) -> bool {
65 let trimmed = summary.trim_end_matches('.');
66 let Some((_, tail)) = trimmed.rsplit_once(", ") else {
67 return false;
68 };
69 let Some(number) = tail.strip_suffix(" bytes") else {
70 return false;
71 };
72 !number.is_empty() && number.chars().all(|character| character.is_ascii_digit())
73}
74
75pub(crate) fn document_summary_from_facts(facts: &projectatlas_symbols::DocumentFacts) -> String {
77 let prefix = facts
78 .text
79 .split_whitespace()
80 .flat_map(|word| word.chars().chain(std::iter::once(' ')))
81 .take(LABEL_LIMIT + 1)
82 .collect::<String>();
83 let excerpt = prefix.trim_end();
84 if excerpt.is_empty() {
85 format!("{} document with no extracted text.", facts.format)
86 } else {
87 let excerpt = if excerpt.chars().count() > LABEL_LIMIT {
88 truncate_chars(excerpt, LABEL_LIMIT)
89 } else {
90 excerpt.to_owned()
91 };
92 format!("{} document text: {excerpt}", facts.format)
93 }
94}
95
96fn markdown_summary(content: &str) -> Option<String> {
98 markdown_summary_from_facts(
99 &extract_markdown_facts(content),
100 content
101 .lines()
102 .filter(|line| !line.trim().is_empty())
103 .count(),
104 )
105}
106
107pub(crate) fn markdown_summary_from_facts(
109 facts: &MarkdownFacts,
110 non_empty_lines: usize,
111) -> Option<String> {
112 let headings = &facts.headings;
113 if headings.is_empty() {
114 return (non_empty_lines > 0)
115 .then(|| format!("markdown document with {non_empty_lines} non-empty lines."));
116 }
117 let title = headings
118 .iter()
119 .find(|heading| heading.level == 1)
120 .unwrap_or(&headings[0]);
121 let title_text = compact_label(&title.text);
122 let section_names = headings
123 .iter()
124 .filter(|heading| heading.text != title.text)
125 .map(|heading| compact_label(&heading.text))
126 .collect::<Vec<_>>();
127 if section_names.is_empty() {
128 Some(format!("markdown document titled {title_text}."))
129 } else {
130 Some(format!(
131 "markdown document titled {} with sections {}.",
132 title_text,
133 join_limited(section_names.iter().map(String::as_str).collect())
134 ))
135 }
136}
137
138fn json_summary(path: &str, content: &str) -> Option<String> {
140 let value: JsonValue = parse_to_serde_value(content, &JsoncParseOptions::default()).ok()?;
141 let object = value.as_object()?;
142 if path.ends_with("package.json") {
143 return Some(package_json_summary(object));
144 }
145 let keys = object.keys().map(String::as_str).collect::<Vec<_>>();
146 if object.contains_key("datasets") || path.ends_with("datasets.manifest.json") {
147 let (dataset_count, dataset_ids) = dataset_manifest_facts(object.get("datasets"));
148 let key_list = join_limited(keys);
149 if dataset_ids.is_empty() {
150 return Some(format!(
151 "json dataset manifest with {dataset_count} datasets and keys {key_list}."
152 ));
153 }
154 return Some(format!(
155 "json dataset manifest with {dataset_count} datasets including {} and keys {key_list}.",
156 join_limited(dataset_ids.iter().map(String::as_str).collect())
157 ));
158 }
159 if keys.is_empty() {
160 None
161 } else {
162 Some(format!(
163 "json document with top-level keys {}.",
164 join_limited(keys)
165 ))
166 }
167}
168
169fn dataset_manifest_facts(value: Option<&JsonValue>) -> (usize, Vec<String>) {
171 let Some(value) = value else {
172 return (0, Vec::new());
173 };
174 if let Some(object) = value.as_object() {
175 let ids = object.keys().cloned().collect::<Vec<_>>();
176 return (ids.len(), ids);
177 }
178 let Some(array) = value.as_array() else {
179 return (0, Vec::new());
180 };
181 let ids = array
182 .iter()
183 .filter_map(|item| {
184 item.as_object().and_then(|object| {
185 object
186 .get("id")
187 .or_else(|| object.get("name"))
188 .and_then(JsonValue::as_str)
189 .map(compact_label)
190 })
191 })
192 .collect::<Vec<_>>();
193 (array.len(), ids)
194}
195
196fn package_json_summary(object: &serde_json::Map<String, JsonValue>) -> String {
198 let name = object
199 .get("name")
200 .and_then(JsonValue::as_str)
201 .map_or_else(|| "unnamed package".to_string(), compact_label);
202 let script_names = object_keys(object.get("scripts"));
203 let dependency_names = object_keys(object.get("dependencies"));
204 let dev_dependency_names = object_keys(object.get("devDependencies"));
205 let dependencies = dependency_names
206 .len()
207 .saturating_add(dev_dependency_names.len());
208 if script_names.is_empty() && dependencies == 0 {
209 format!("package manifest for {name}.")
210 } else if script_names.is_empty() {
211 format!("package manifest for {name} with {dependencies} dependencies.")
212 } else {
213 format!(
214 "package manifest for {name} with scripts {} and {dependencies} dependencies.",
215 join_limited(script_names.iter().map(String::as_str).collect())
216 )
217 }
218}
219
220fn object_keys(value: Option<&JsonValue>) -> Vec<String> {
222 let mut keys = value
223 .and_then(JsonValue::as_object)
224 .map(|object| object.keys().cloned().collect::<Vec<_>>())
225 .unwrap_or_default();
226 keys.sort();
227 keys
228}
229
230fn yaml_summary(path: &str, content: &str) -> Option<String> {
232 let document = YamlLoader::load_from_str(content)
233 .ok()?
234 .into_iter()
235 .next()?;
236 let keys = yaml_mapping_keys(&document);
237 let jobs = yaml_child_mapping_keys(&document, "jobs");
238 let triggers = yaml_triggers(&document);
239 let workflow_name = yaml_scalar_value(&document, "name");
240 if path.contains(".github/workflows/") || (!jobs.is_empty() && !triggers.is_empty()) {
241 let name = workflow_name
242 .as_deref()
243 .map_or_else(|| "unnamed workflow".to_string(), compact_label);
244 return Some(format!(
245 "yaml workflow {name} triggered by {} with jobs {}.",
246 join_limited(triggers.iter().map(String::as_str).collect()),
247 join_limited(jobs.iter().map(String::as_str).collect())
248 ));
249 }
250 if keys.is_empty() {
251 None
252 } else {
253 Some(format!(
254 "yaml document with top-level keys {}.",
255 join_limited(keys.iter().map(String::as_str).collect())
256 ))
257 }
258}
259
260fn yaml_mapping_keys(value: &Yaml) -> Vec<String> {
262 let mut keys = value
263 .as_hash()
264 .map(|hash| {
265 hash.iter()
266 .filter_map(|(key, _value)| key.as_str().map(compact_label))
267 .collect::<Vec<_>>()
268 })
269 .unwrap_or_default();
270 keys.sort();
271 keys.dedup();
272 keys
273}
274
275fn yaml_child_mapping_keys(value: &Yaml, key: &str) -> Vec<String> {
277 yaml_mapping_keys(&value[key])
278}
279
280fn yaml_triggers(value: &Yaml) -> Vec<String> {
282 let trigger = &value["on"];
283 if let Some(trigger) = trigger.as_str() {
284 return trigger
285 .trim_matches(['[', ']'])
286 .split(',')
287 .map(str::trim)
288 .filter(|item| !item.is_empty())
289 .map(compact_label)
290 .collect();
291 }
292 if let Some(triggers) = trigger.as_vec() {
293 return triggers
294 .iter()
295 .filter_map(Yaml::as_str)
296 .map(compact_label)
297 .collect();
298 }
299 yaml_mapping_keys(trigger)
300}
301
302fn yaml_scalar_value(value: &Yaml, key: &str) -> Option<String> {
304 value[key].as_str().map(ToString::to_string)
305}
306
307fn toml_summary(path: &str, content: &str) -> Option<String> {
309 let value = toml::from_str::<TomlValue>(content).ok()?;
310 let table = value.as_table()?;
311 let keys = table.keys().map(String::as_str).collect::<Vec<_>>();
312 if path.ends_with("Cargo.toml") {
313 let package = table
314 .get("package")
315 .and_then(TomlValue::as_table)
316 .and_then(|package| package.get("name"))
317 .and_then(TomlValue::as_str)
318 .map_or_else(|| "workspace".to_string(), compact_label);
319 return Some(format!(
320 "cargo manifest for {package} with tables {}.",
321 join_limited(keys)
322 ));
323 }
324 if path.ends_with(".projectatlas/config.toml") || path.ends_with("projectatlas.toml") {
325 let excludes = table
326 .get("scan")
327 .and_then(TomlValue::as_table)
328 .map_or(0, toml_scan_exclude_count);
329 return Some(format!(
330 "ProjectAtlas config with tables {} and {excludes} scan excludes.",
331 join_limited(keys)
332 ));
333 }
334 if keys.is_empty() {
335 None
336 } else {
337 Some(format!("toml document with tables {}.", join_limited(keys)))
338 }
339}
340
341fn toml_scan_exclude_count(scan: &toml::map::Map<String, TomlValue>) -> usize {
343 ["exclude_dir_names", "exclude_path_prefixes"]
344 .iter()
345 .filter_map(|key| scan.get(*key))
346 .filter_map(TomlValue::as_array)
347 .map(Vec::len)
348 .sum()
349}
350
351fn css_summary(content: &str) -> Option<String> {
353 let mut input = CssParserInput::new(content);
354 let mut parser = CssParser::new(&mut input);
355 let mut facts = CssFacts::default();
356 scan_css_tokens(&mut parser, CssMode::StyleSheet, &mut facts);
357 if facts.selectors.is_empty() && facts.custom_properties.is_empty() {
358 return None;
359 }
360 let selector_list = facts
361 .selectors
362 .iter()
363 .map(String::as_str)
364 .collect::<Vec<_>>();
365 let property_list = facts
366 .custom_properties
367 .iter()
368 .map(String::as_str)
369 .collect::<Vec<_>>();
370 Some(format!(
371 "css stylesheet with selectors {}, custom properties {}, {} media queries, and {} supports queries.",
372 join_or_none(selector_list),
373 join_or_none(property_list),
374 facts.media_queries,
375 facts.supports_queries
376 ))
377}
378
379#[derive(Clone, Copy, Eq, PartialEq)]
381enum CssMode {
382 StyleSheet,
384 DeclarationBlock,
386}
387
388#[derive(Clone, Copy, Eq, PartialEq)]
390enum CssRuleKind {
391 None,
393 Media,
395 Supports,
397 Qualified,
399}
400
401#[derive(Default)]
403struct CssFacts {
404 selectors: BTreeSet<String>,
406 custom_properties: BTreeSet<String>,
408 media_queries: usize,
410 supports_queries: usize,
412}
413
414fn scan_css_tokens(parser: &mut CssParser<'_, '_>, mode: CssMode, facts: &mut CssFacts) {
416 let mut pending_delimiter: Option<char> = None;
417 let mut after_colon = false;
418 let mut rule_kind = CssRuleKind::None;
419 while let Ok(token) = parser.next_including_whitespace_and_comments().cloned() {
420 match token {
421 CssToken::AtKeyword(name) => {
422 if name.eq_ignore_ascii_case("media") {
423 facts.media_queries = facts.media_queries.saturating_add(1);
424 rule_kind = CssRuleKind::Media;
425 } else if name.eq_ignore_ascii_case("supports") {
426 facts.supports_queries = facts.supports_queries.saturating_add(1);
427 rule_kind = CssRuleKind::Supports;
428 }
429 }
430 CssToken::IDHash(name) | CssToken::Hash(name) if mode == CssMode::StyleSheet => {
431 facts.selectors.insert(format!("#{}", compact_label(&name)));
432 rule_kind = CssRuleKind::Qualified;
433 }
434 CssToken::Delim('.') if mode == CssMode::StyleSheet => {
435 pending_delimiter = Some('.');
436 }
437 CssToken::Colon if mode == CssMode::StyleSheet => {
438 after_colon = true;
439 }
440 CssToken::Ident(name) => {
441 let name = name.as_ref();
442 if name.starts_with("--") {
443 facts.custom_properties.insert(compact_label(name));
444 } else if mode == CssMode::StyleSheet {
445 if let Some(delimiter) = pending_delimiter.take() {
446 facts
447 .selectors
448 .insert(format!("{delimiter}{}", compact_label(name)));
449 rule_kind = CssRuleKind::Qualified;
450 } else if after_colon {
451 facts.selectors.insert(format!(":{}", compact_label(name)));
452 rule_kind = CssRuleKind::Qualified;
453 } else if is_css_type_selector(name) {
454 facts.selectors.insert(compact_label(name));
455 rule_kind = CssRuleKind::Qualified;
456 }
457 after_colon = false;
458 }
459 }
460 CssToken::Comma if mode == CssMode::StyleSheet => {
461 after_colon = false;
462 pending_delimiter = None;
463 }
464 CssToken::CurlyBracketBlock => {
465 let nested_mode = if matches!(rule_kind, CssRuleKind::Media | CssRuleKind::Supports)
466 {
467 CssMode::StyleSheet
468 } else {
469 CssMode::DeclarationBlock
470 };
471 let nested_result: Result<(), cssparser::ParseError<'_, ()>> = parser
472 .parse_nested_block(|nested| {
473 scan_css_tokens(nested, nested_mode, facts);
474 Ok(())
475 });
476 drop(nested_result);
477 after_colon = false;
478 pending_delimiter = None;
479 rule_kind = CssRuleKind::None;
480 }
481 _ => {}
482 }
483 }
484}
485
486fn is_css_type_selector(name: &str) -> bool {
488 matches!(
489 name.to_ascii_lowercase().as_str(),
490 "body" | "html" | "main" | "section" | "article" | "header" | "footer"
491 )
492}
493
494fn html_summary(content: &str) -> Option<String> {
496 let document = Html::parse_document(content);
497 let title = first_html_text(&document, "title");
498 let description = first_html_attribute(
499 &document,
500 "meta[name=\"description\"], meta[property=\"description\"], meta[property=\"og:description\"]",
501 "content",
502 );
503 let headings = html_texts(&document, "h1, h2", LIST_LIMIT);
504 let link_rels = html_link_rel_values(&document, LIST_LIMIT);
505 let has_structured_data = html_select(&document, "script[type=\"application/ld+json\"]")
506 .is_some_and(|selector| document.select(&selector).next().is_some());
507 if title.is_none()
508 && description.is_none()
509 && headings.is_empty()
510 && link_rels.is_empty()
511 && !has_structured_data
512 {
513 return None;
514 }
515 let mut parts = Vec::new();
516 if let Some(title) = title {
517 parts.push(format!("title {title}"));
518 }
519 if let Some(description) = description {
520 parts.push(format!("meta description {description}"));
521 }
522 if !headings.is_empty() {
523 parts.push(format!(
524 "headings {}",
525 join_limited(headings.iter().map(String::as_str).collect())
526 ));
527 }
528 if !link_rels.is_empty() {
529 parts.push(format!(
530 "link rels {}",
531 join_limited(link_rels.iter().map(String::as_str).collect())
532 ));
533 }
534 if has_structured_data {
535 parts.push("structured data".to_string());
536 }
537 Some(format!("html document with {}.", parts.join(", ")))
538}
539
540fn html_link_rel_values(document: &Html, limit: usize) -> Vec<String> {
542 let Some(selector) = html_select(document, "link[rel]") else {
543 return Vec::new();
544 };
545 let mut rels = document
546 .select(&selector)
547 .filter_map(|element| element.attr("rel"))
548 .flat_map(|value| {
549 value
550 .split_ascii_whitespace()
551 .map(compact_label)
552 .collect::<Vec<_>>()
553 })
554 .filter(|value| !value.is_empty())
555 .collect::<BTreeSet<_>>()
556 .into_iter()
557 .collect::<Vec<_>>();
558 rels.truncate(limit);
559 rels
560}
561
562fn powershell_summary(content: &str) -> Option<String> {
564 let functions = content
565 .lines()
566 .filter_map(powershell_function_name)
567 .collect::<BTreeSet<_>>()
568 .into_iter()
569 .collect::<Vec<_>>();
570 if !functions.is_empty() {
571 return Some(format!(
572 "powershell script defining functions {}.",
573 join_limited(functions.iter().map(String::as_str).collect())
574 ));
575 }
576 let lines = content
577 .lines()
578 .filter(|line| !line.trim().is_empty())
579 .count();
580 (lines > 0).then(|| format!("powershell script with {lines} non-empty lines."))
581}
582
583fn powershell_function_name(line: &str) -> Option<String> {
585 let trimmed = line.trim_start();
586 let mut parts = trimmed.split_whitespace();
587 if !parts.next()?.eq_ignore_ascii_case("function") {
588 return None;
589 }
590 let raw_name = parts.next()?;
591 let name = raw_name.split(['(', '{']).next().unwrap_or_default().trim();
592 let name = name.rsplit_once(':').map_or(name, |(_, scoped)| scoped);
593 let valid = !name.is_empty()
594 && name
595 .chars()
596 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'));
597 valid.then(|| compact_label(name))
598}
599
600fn html_select(_document: &Html, selector: &str) -> Option<Selector> {
602 Selector::parse(selector).ok()
603}
604
605fn first_html_text(document: &Html, selector: &str) -> Option<String> {
607 html_texts(document, selector, 1).into_iter().next()
608}
609
610fn html_texts(document: &Html, selector: &str, limit: usize) -> Vec<String> {
612 let Some(selector) = html_select(document, selector) else {
613 return Vec::new();
614 };
615 document
616 .select(&selector)
617 .filter_map(|element| {
618 let text = element.text().collect::<Vec<_>>().join(" ");
619 let text = compact_label(&text);
620 (!text.is_empty()).then_some(text)
621 })
622 .take(limit)
623 .collect()
624}
625
626fn first_html_attribute(document: &Html, selector: &str, attribute: &str) -> Option<String> {
628 let selector = html_select(document, selector)?;
629 document
630 .select(&selector)
631 .find_map(|element| element.attr(attribute))
632 .map(compact_label)
633}
634
635fn toon_summary(content: &str) -> Option<String> {
637 let mut sections = if let Ok(value) = toon_format::decode_default::<JsonValue>(content) {
638 value
639 .as_object()
640 .map(|object| object.keys().cloned().collect::<Vec<_>>())
641 .unwrap_or_default()
642 } else {
643 Vec::new()
644 };
645 if sections.is_empty() {
646 sections = toon_section_names(content)
647 .into_iter()
648 .map(str::to_string)
649 .collect();
650 }
651 if sections.is_empty() {
652 None
653 } else {
654 Some(format!(
655 "TOON document with sections {}.",
656 join_limited(sections.iter().map(String::as_str).collect())
657 ))
658 }
659}
660
661fn toon_section_names(content: &str) -> Vec<&str> {
663 content
664 .lines()
665 .filter_map(|line| {
666 let trimmed = line.trim();
667 let (head, _tail) = trimmed.split_once(':')?;
668 let name = head
669 .split_once('[')
670 .map_or(head, |(name, _columns)| name)
671 .split_once('{')
672 .map_or_else(
673 || head.split_once('[').map_or(head, |(name, _columns)| name),
674 |(name, _columns)| name,
675 )
676 .trim();
677 (!name.is_empty() && !name.starts_with('#')).then_some(name)
678 })
679 .collect()
680}
681
682fn xml_summary(content: &str) -> Option<String> {
684 let elements = content
685 .lines()
686 .flat_map(xml_element_names)
687 .collect::<BTreeSet<_>>()
688 .into_iter()
689 .collect::<Vec<_>>();
690 if elements.is_empty() {
691 None
692 } else {
693 Some(format!(
694 "xml document with elements {}.",
695 join_limited(elements.iter().map(String::as_str).collect())
696 ))
697 }
698}
699
700fn xml_element_names(line: &str) -> Vec<String> {
702 let mut names = Vec::new();
703 let mut rest = line;
704 while let Some((_, after_open)) = rest.split_once('<') {
705 let trimmed = after_open.trim_start();
706 if trimmed.starts_with(['/', '!', '?']) {
707 rest = trimmed.get(1..).unwrap_or_default();
708 continue;
709 }
710 let name = trimmed
711 .split(|character: char| character.is_whitespace() || matches!(character, '/' | '>'))
712 .next()
713 .unwrap_or_default();
714 if !name.is_empty()
715 && name.chars().all(|character| {
716 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | ':' | '.')
717 })
718 {
719 names.push(name.to_string());
720 }
721 rest = trimmed
722 .split_once('>')
723 .map_or("", |(_element, remaining)| remaining);
724 }
725 names
726}
727
728fn config_text_summary(path: &str, content: &str) -> Option<String> {
730 let keys = content
731 .lines()
732 .filter_map(config_key)
733 .collect::<BTreeSet<_>>()
734 .into_iter()
735 .collect::<Vec<_>>();
736 if keys.is_empty() {
737 if has_extension(path, "txt") {
738 let excerpt = content
739 .lines()
740 .map(compact_label)
741 .find(|line| !line.is_empty())?;
742 let file_name = path.rsplit('/').next().unwrap_or(path);
743 return Some(format!("text file {file_name} beginning with {excerpt}."));
744 }
745 return None;
746 }
747 let file_name = path.rsplit('/').next().unwrap_or(path);
748 Some(format!(
749 "config file {file_name} with keys {}.",
750 join_limited(keys.iter().map(String::as_str).collect())
751 ))
752}
753
754fn config_key(line: &str) -> Option<String> {
756 let trimmed = line.trim();
757 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
758 return None;
759 }
760 let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
761 for separator in ['=', ':'] {
762 if let Some((key, _value)) = trimmed.split_once(separator) {
763 let key = key.trim();
764 if !key.is_empty()
765 && key.chars().all(|character| {
766 character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.')
767 })
768 {
769 return Some(key.to_string());
770 }
771 }
772 }
773 None
774}
775
776fn join_limited(values: Vec<&str>) -> String {
778 let mut values = values
779 .into_iter()
780 .map(compact_label)
781 .filter(|value| !value.is_empty())
782 .collect::<Vec<_>>();
783 values.sort();
784 values.dedup();
785 let extra = values.len().saturating_sub(LIST_LIMIT);
786 values.truncate(LIST_LIMIT);
787 let joined = values.join(", ");
788 if extra == 0 {
789 joined
790 } else {
791 format!("{joined}, and {extra} more")
792 }
793}
794
795fn join_or_none(values: Vec<&str>) -> String {
797 if values.is_empty() {
798 "none".to_string()
799 } else {
800 join_limited(values)
801 }
802}
803
804fn compact_label(text: &str) -> String {
806 let compact = text
807 .split_whitespace()
808 .collect::<Vec<_>>()
809 .join(" ")
810 .trim_matches(['"', '\''])
811 .trim_end_matches(['#'])
812 .trim()
813 .to_string();
814 if compact.chars().count() <= LABEL_LIMIT {
815 compact
816 } else {
817 truncate_chars(&compact, LABEL_LIMIT)
818 }
819}
820
821fn truncate_chars(text: &str, limit: usize) -> String {
823 let mut output = text
824 .chars()
825 .take(limit.saturating_sub(3))
826 .collect::<String>();
827 output.push_str("...");
828 output
829}
830
831#[cfg(test)]
832mod tests {
833 use super::{is_scanner_fallback_summary, structural_summary_for_path};
834
835 #[test]
836 fn document_summary_preserves_literal_punctuation_and_bounds_unicode() {
837 use projectatlas_symbols::{
838 DocumentCompleteness, DocumentFacts, DocumentFormat, DocumentParserProvenance,
839 };
840 let mut facts = DocumentFacts {
841 format: DocumentFormat::Docx,
842 text: "\"#\"\n\t#".to_owned(),
843 facts: Vec::new(),
844 completeness: DocumentCompleteness::Complete,
845 provenance: DocumentParserProvenance::QuickXml,
846 };
847 assert_eq!(
848 super::document_summary_from_facts(&facts),
849 "docx document text: \"#\" #"
850 );
851 facts.text = "\n\t".to_owned() + &"界".repeat(1000);
852 assert_eq!(
853 super::document_summary_from_facts(&facts),
854 format!("docx document text: {}...", "界".repeat(77))
855 );
856 facts.text = " \n\t".to_owned();
857 assert_eq!(
858 super::document_summary_from_facts(&facts),
859 "docx document with no extracted text."
860 );
861 }
862
863 #[test]
864 fn summarizes_markdown_headings() {
865 let summary = structural_summary_for_path(
866 "README.md",
867 Some("markdown"),
868 "# ProjectAtlas\n\n## Install\nUsage\n-----\n",
869 );
870 assert_eq!(
871 summary.as_deref(),
872 Some("markdown document titled ProjectAtlas with sections Install, Usage.")
873 );
874 }
875
876 #[test]
877 fn summarizes_package_jsonc() {
878 let summary = structural_summary_for_path(
879 "package.json",
880 Some("json"),
881 "{\n // project name\n \"name\":\"demo\",\n \"scripts\":{\"test\":\"vitest\"},\n \"dependencies\":{\"react\":\"1.0.0\"}\n}",
882 );
883 assert_eq!(
884 summary.as_deref(),
885 Some("package manifest for demo with scripts test and 1 dependencies.")
886 );
887 }
888
889 #[test]
890 fn summarizes_object_keyed_dataset_manifest() {
891 let summary = structural_summary_for_path(
892 "app/public/data/datasets.manifest.json",
893 Some("json"),
894 r#"{
895 "generated_at": "2026-06-28T00:00:00Z",
896 "version": "2026.06.28",
897 "datasets": {
898 "catalog.primary": {"path": "primary.json"},
899 "catalog.secondary": {"path": "secondary.json"},
900 "catalog.archive": {"path": "archive.json"}
901 }
902}"#,
903 );
904 assert_eq!(
905 summary.as_deref(),
906 Some(
907 "json dataset manifest with 3 datasets including catalog.archive, catalog.primary, catalog.secondary and keys datasets, generated_at, version."
908 )
909 );
910 }
911
912 #[test]
913 fn summarizes_workflow_yaml() {
914 let summary = structural_summary_for_path(
915 ".github/workflows/ci.yml",
916 Some("yaml"),
917 "name: CI\non:\n push:\n pull_request:\njobs:\n test:\n runs-on: ubuntu-latest\n",
918 );
919 assert_eq!(
920 summary.as_deref(),
921 Some("yaml workflow CI triggered by pull_request, push with jobs test.")
922 );
923 }
924
925 #[test]
926 fn summarizes_projectatlas_config_toml() {
927 let summary = structural_summary_for_path(
928 ".projectatlas/config.toml",
929 Some("toml"),
930 "[project]\nroot = \".\"\n[scan]\nexclude_dir_names = [\"target\"]\nexclude_path_prefixes = [\"docs/api\"]\n",
931 );
932 assert_eq!(
933 summary.as_deref(),
934 Some("ProjectAtlas config with tables project, scan and 2 scan excludes.")
935 );
936 }
937
938 #[test]
939 fn summarizes_xml_elements() {
940 let summary = structural_summary_for_path(
941 "config/routes.xml",
942 Some("xml"),
943 r#"<?xml version="1.0"?>
944<routes>
945 <route id="home" />
946 <route id="about"></route>
947</routes>
948"#,
949 );
950 assert_eq!(
951 summary.as_deref(),
952 Some("xml document with elements route, routes.")
953 );
954 }
955
956 #[test]
957 fn summarizes_css_structure() {
958 let summary = structural_summary_for_path(
959 "app/styles.css",
960 Some("css"),
961 ":root { --brand: #fff; }\n.card, .panel { color: red; }\n@media (min-width: 40rem) { .card { display: grid; } }\n",
962 );
963 assert_eq!(
964 summary.as_deref(),
965 Some(
966 "css stylesheet with selectors .card, .panel, :root, custom properties --brand, 1 media queries, and 0 supports queries."
967 )
968 );
969 }
970
971 #[test]
972 fn summarizes_html_metadata() {
973 let summary = structural_summary_for_path(
974 "index.html",
975 Some("html"),
976 "<html><head><title>Home</title><meta name=\"description\" content=\"Welcome page\"><link rel=\"canonical\" href=\"https://example.test/\"><link rel=\"manifest\" href=\"/site.webmanifest\"><link rel=\"alternate\" href=\"/de/\"></head><body><h1>Hello</h1><script type=\"application/ld+json\">{}</script></body></html>",
977 );
978 assert_eq!(
979 summary.as_deref(),
980 Some(
981 "html document with title Home, meta description Welcome page, headings Hello, link rels alternate, canonical, manifest, structured data."
982 )
983 );
984 }
985
986 #[test]
987 fn summarizes_powershell_functions() {
988 let summary = structural_summary_for_path(
989 "scripts/install-runtime.ps1",
990 Some("powershell"),
991 "function Resolve-DefaultProjectRoot {\n}\nfunction global:Get-ReleaseRuntimeInstallPath($Root) {\n}\nfunction Install-ReleaseBinary {\n}\n",
992 );
993 assert_eq!(
994 summary.as_deref(),
995 Some(
996 "powershell script defining functions Get-ReleaseRuntimeInstallPath, Install-ReleaseBinary, Resolve-DefaultProjectRoot."
997 )
998 );
999 }
1000
1001 #[test]
1002 fn supplied_language_overrides_structural_path_owner() {
1003 assert_eq!(
1004 structural_summary_for_path("Cargo.toml", Some("json"), "{\"name\":\"atlas\"}",)
1005 .as_deref(),
1006 Some("json document with top-level keys name.")
1007 );
1008 assert_eq!(
1009 structural_summary_for_path("scripts/install.ps1", Some("text"), "name=atlas\n",)
1010 .as_deref(),
1011 Some("config file install.ps1 with keys name.")
1012 );
1013 assert_eq!(
1014 structural_summary_for_path("data/report.toon", Some("json"), "{\"items\":[]}",)
1015 .as_deref(),
1016 Some("json document with top-level keys items.")
1017 );
1018 assert!(
1019 structural_summary_for_path("data/report.toon", Some("rust"), "items[1]{id}:\n 1\n",)
1020 .is_none()
1021 );
1022 assert_eq!(
1023 structural_summary_for_path("data/report.txt", Some("toon"), "items[1]{id}:\n 1\n",)
1024 .as_deref(),
1025 Some("TOON document with sections items.")
1026 );
1027 }
1028
1029 #[test]
1030 fn missing_language_preserves_toon_path_inference() {
1031 assert_eq!(
1032 structural_summary_for_path("data/report.toon", None, "items[1]{id}:\n 1\n",)
1033 .as_deref(),
1034 Some("TOON document with sections items.")
1035 );
1036 }
1037
1038 #[test]
1039 fn summarizes_plain_text_excerpt() {
1040 let summary =
1041 structural_summary_for_path("notes.txt", Some("text"), "\n\nProjectAtlas notes\n");
1042 assert_eq!(
1043 summary.as_deref(),
1044 Some("text file notes.txt beginning with ProjectAtlas notes.")
1045 );
1046 }
1047
1048 #[test]
1049 fn classifies_summary_quality() {
1050 assert!(is_scanner_fallback_summary("rust file, 120 bytes."));
1051 }
1052}