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