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