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