projectatlas/
structural.rs

1//! Deterministic structural summaries for files without declaration symbols.
2
3use 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
16/// Maximum named items rendered into one structural summary list.
17const LIST_LIMIT: usize = 4;
18
19/// Maximum characters retained for a single extracted label.
20const LABEL_LIMIT: usize = 80;
21
22/// Build a deterministic one-line structural summary for an indexed file.
23pub(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
43/// Return whether a file family has a lightweight structural adapter.
44pub(crate) fn is_structural_summary_candidate(path: &str, language: Option<&str>) -> bool {
45    structural_summary_owner(path, language).is_some()
46}
47
48/// Select the registry-owned structural adapter, with legacy TOON inference when undetected.
49fn 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
58/// Return whether a repository path has a case-insensitive extension.
59fn 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
66/// Return whether a stored summary is still only the scanner byte fallback.
67pub(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
78/// Summarize a Markdown or MDX document from parsed `CommonMark` headings.
79fn 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
108/// One parsed Markdown heading.
109struct MarkdownHeading {
110    /// Heading level from one to six.
111    level: usize,
112    /// Compact heading text.
113    text: String,
114}
115
116/// Extract Markdown headings through `pulldown-cmark`.
117fn 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
151/// Convert a Markdown heading level into a display depth.
152fn 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
163/// Summarize JSON and JSONC files from parsed object structure.
164fn 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
194/// Extract dataset count and bounded identifiers from common manifest shapes.
195fn 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
221/// Build a package.json summary from common manifest keys.
222fn 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
245/// Return sorted object keys for a JSON object value.
246fn 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
255/// Summarize YAML files, including GitHub Actions workflow structure.
256fn 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
285/// Extract mapping keys from a YAML node.
286fn 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
300/// Extract child mapping keys from a top-level YAML key.
301fn yaml_child_mapping_keys(value: &Yaml, key: &str) -> Vec<String> {
302    yaml_mapping_keys(&value[key])
303}
304
305/// Extract GitHub Actions trigger names from a YAML document.
306fn 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
327/// Extract a scalar string for a top-level YAML key.
328fn yaml_scalar_value(value: &Yaml, key: &str) -> Option<String> {
329    value[key].as_str().map(ToString::to_string)
330}
331
332/// Summarize TOML configuration and manifest files.
333fn 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
366/// Count configured scan exclude entries in a TOML scan table.
367fn 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
376/// Summarize CSS-like stylesheets from parser tokens.
377fn 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/// CSS parser traversal mode.
405#[derive(Clone, Copy, Eq, PartialEq)]
406enum CssMode {
407    /// Parse rule preludes as stylesheet selectors.
408    StyleSheet,
409    /// Parse rule bodies as declarations.
410    DeclarationBlock,
411}
412
413/// Kind of CSS rule prelude most recently seen.
414#[derive(Clone, Copy, Eq, PartialEq)]
415enum CssRuleKind {
416    /// No rule prelude has been classified yet.
417    None,
418    /// The prelude is an `@media` rule.
419    Media,
420    /// The prelude is an `@supports` rule.
421    Supports,
422    /// The prelude is a qualified selector rule.
423    Qualified,
424}
425
426/// Structural facts extracted from CSS tokens.
427#[derive(Default)]
428struct CssFacts {
429    /// Selector labels from qualified rules.
430    selectors: BTreeSet<String>,
431    /// Custom property names declared in rule bodies.
432    custom_properties: BTreeSet<String>,
433    /// Number of media query at-rules.
434    media_queries: usize,
435    /// Number of supports query at-rules.
436    supports_queries: usize,
437}
438
439/// Walk CSS parser tokens and collect stable structural facts.
440fn 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
511/// Return whether an identifier is a useful type selector label.
512fn 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
519/// Summarize HTML documents from parsed title, metadata, headings, and structured data.
520fn 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
565/// Extract bounded link relation markers from an HTML document.
566fn 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
587/// Summarize `PowerShell` scripts from declared function names.
588fn 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
608/// Extract one `PowerShell` function declaration name.
609fn 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
625/// Compile an HTML selector.
626fn html_select(_document: &Html, selector: &str) -> Option<Selector> {
627    Selector::parse(selector).ok()
628}
629
630/// Extract first text value for an HTML selector.
631fn first_html_text(document: &Html, selector: &str) -> Option<String> {
632    html_texts(document, selector, 1).into_iter().next()
633}
634
635/// Extract text values for an HTML selector.
636fn 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
651/// Extract one attribute from the first matching HTML element.
652fn 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
660/// Summarize TOON files from the decoded top-level shape.
661fn 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
686/// Extract declared top-level TOON section names from compact table/list headers.
687fn 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
707/// Summarize XML-like documents from their declared element names.
708fn 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
725/// Extract XML element names from one line with conservative string scanning.
726fn 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
753/// Summarize simple config or text files from key-like lines.
754fn 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
779/// Extract a key from simple `key=value`, `key: value`, or `export key=value` lines.
780fn 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
801/// Join a list with a fixed display limit.
802fn 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
820/// Join a list or return `none` when it is empty.
821fn 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
829/// Compact a label into one line.
830fn 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
846/// Truncate a string to a character boundary and add an ellipsis marker.
847fn 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}