Skip to main content

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