Skip to main content

formal_ai/summarization/
resource.rs

1//! Recursive repository-resource formalization and summarization.
2//!
3//! This layer generalizes the file summarizer in [`super::file`] from "one
4//! file" to "any repository resource": a single file **or** a directory
5//! (folder) tree of arbitrary depth. It exists because summarizing only files
6//! is too specialized — a repository is a recursive tree of folders and files,
7//! and the same summarization must apply at every level.
8//!
9//! A directory is summarized by the project's meta-algorithm loop applied
10//! recursively:
11//!
12//! 1. **Decompose.** A directory is split into its child resources (files and
13//!    subdirectories).
14//! 2. **Solve each.** Every child is formalized and summarized on its own —
15//!    files through [`super::file`], subdirectories by recursing into this same
16//!    function.
17//! 3. **Compose.** The child summaries are composed back into one directory
18//!    summary, together with aggregate metadata (file/subdirectory counts, total
19//!    lines and bytes).
20//!
21//! Recursion depth is bounded by the summarization *mode ladder*
22//! ([`SummarizationMode::one_step_shorter`]): each level of nesting is rendered
23//! one mode shorter than its parent, so a `Full` directory summary describes its
24//! direct children in `Standard` detail, their children in `Short` detail, and
25//! everything deeper as a `Topic` label. This keeps deep trees bounded while
26//! still surfacing the most important structure first.
27//!
28//! Everything here is a pure function of its input tree plus the
29//! [`SummarizationConfig`]: no filesystem access happens inside this module, so
30//! it stays deterministic and testable. Callers that walk a real directory build
31//! a [`RepositoryEntry`] tree first and pass it in.
32
33use std::fmt::Write as _;
34
35use crate::links_format::flatten_lino_value;
36
37use super::file::{formalize_repository_file, RepositoryFileFormalization};
38use super::{SummarizationConfig, SummarizationMode};
39
40/// Input tree describing a repository resource to formalize.
41///
42/// This is the deterministic, filesystem-free representation a caller builds
43/// before formalization. A real directory walk maps to nested
44/// [`RepositoryEntry::Directory`] / [`RepositoryEntry::File`] nodes.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum RepositoryEntry {
47    /// A single file with its repository-relative path and full content.
48    File { path: String, content: String },
49    /// A directory with its repository-relative path and ordered children.
50    Directory { path: String, children: Vec<Self> },
51}
52
53impl RepositoryEntry {
54    /// Build a file entry.
55    #[must_use]
56    pub fn file(path: impl Into<String>, content: impl Into<String>) -> Self {
57        Self::File {
58            path: path.into(),
59            content: content.into(),
60        }
61    }
62
63    /// Build a directory entry from an ordered list of children.
64    #[must_use]
65    pub fn directory(path: impl Into<String>, children: Vec<Self>) -> Self {
66        Self::Directory {
67            path: path.into(),
68            children,
69        }
70    }
71
72    /// The repository-relative path of this entry, regardless of kind.
73    #[must_use]
74    pub fn path(&self) -> &str {
75        match self {
76            Self::File { path, .. } | Self::Directory { path, .. } => path,
77        }
78    }
79}
80
81/// Link-native representation of a formalized repository directory.
82#[derive(Debug, Clone)]
83pub struct RepositoryDirectoryFormalization {
84    pub path: String,
85    /// Number of files directly inside this directory (non-recursive).
86    pub direct_file_count: usize,
87    /// Number of subdirectories directly inside this directory (non-recursive).
88    pub direct_directory_count: usize,
89    /// Number of files anywhere in the subtree (recursive).
90    pub total_file_count: usize,
91    /// Number of directories anywhere in the subtree, excluding this one.
92    pub total_directory_count: usize,
93    /// Sum of line counts across every file in the subtree.
94    pub total_line_count: usize,
95    /// Sum of byte counts across every file in the subtree.
96    pub total_byte_count: usize,
97    /// Ordered formalized children (files and subdirectories).
98    pub children: Vec<RepositoryResourceFormalization>,
99}
100
101/// Formalized repository resource: either a file or a directory subtree.
102#[derive(Debug, Clone)]
103pub enum RepositoryResourceFormalization {
104    File(RepositoryFileFormalization),
105    Directory(RepositoryDirectoryFormalization),
106}
107
108impl RepositoryResourceFormalization {
109    /// The repository-relative path of this resource.
110    #[must_use]
111    pub fn path(&self) -> &str {
112        match self {
113            Self::File(file) => &file.path,
114            Self::Directory(directory) => &directory.path,
115        }
116    }
117
118    /// `true` when this resource is a directory.
119    #[must_use]
120    pub const fn is_directory(&self) -> bool {
121        matches!(self, Self::Directory(_))
122    }
123
124    /// Render a prose summary for this resource using the supplied config.
125    ///
126    /// Files defer to [`RepositoryFileFormalization::summary`]; directories use
127    /// the recursive decompose → summarize → compose loop documented at the
128    /// module level.
129    #[must_use]
130    pub fn summary(&self, config: &SummarizationConfig) -> String {
131        match self {
132            Self::File(file) => file.summary(config),
133            Self::Directory(directory) => directory.summary(config),
134        }
135    }
136
137    /// Render the formalized resource as compact indented Links Notation.
138    #[must_use]
139    pub fn links_notation(&self) -> String {
140        match self {
141            Self::File(file) => file.links_notation(),
142            Self::Directory(directory) => directory.links_notation(),
143        }
144    }
145}
146
147impl RepositoryDirectoryFormalization {
148    /// Compose a prose summary for this directory by recursively summarizing its
149    /// children and joining the results behind an aggregate identity sentence.
150    #[must_use]
151    pub fn summary(&self, config: &SummarizationConfig) -> String {
152        let mut parts = vec![self.identity_sentence()];
153
154        if config.mode == SummarizationMode::Topic {
155            // Topic mode is a label, not a body: the identity sentence already
156            // carries the path and aggregate counts.
157            return parts.remove(0);
158        }
159
160        let child_config = config.clone().with_mode(config.mode.one_step_shorter());
161        let cap = child_summary_cap(config.mode);
162        let mut child_summaries = Vec::new();
163        for child in self.children.iter().take(cap) {
164            let summary = child.summary(&child_config);
165            if !summary.is_empty() {
166                child_summaries.push(summary);
167            }
168        }
169        let hidden = self.children.len().saturating_sub(cap);
170
171        if !child_summaries.is_empty() {
172            parts.push(format!("Contents: {}", child_summaries.join(" ")));
173        }
174        if hidden > 0 {
175            parts.push(format!(
176                "{hidden} more {} omitted for brevity.",
177                pluralize(hidden, "entry", "entries")
178            ));
179        }
180        parts.join(" ")
181    }
182
183    fn identity_sentence(&self) -> String {
184        format!(
185            "{} is a repository directory with {} {} and {} {} ({} {} total across {} {}).",
186            self.path,
187            self.direct_file_count,
188            pluralize(self.direct_file_count, "file", "files"),
189            self.direct_directory_count,
190            pluralize(
191                self.direct_directory_count,
192                "subdirectory",
193                "subdirectories"
194            ),
195            self.total_line_count,
196            pluralize(self.total_line_count, "line", "lines"),
197            self.total_file_count,
198            pluralize(self.total_file_count, "file", "files"),
199        )
200    }
201
202    /// Render the formalized directory as compact indented Links Notation.
203    #[must_use]
204    pub fn links_notation(&self) -> String {
205        let mut out = String::from("repository_directory\n");
206        push_field(&mut out, 1, "path", &self.path);
207        push_field(
208            &mut out,
209            1,
210            "direct_file_count",
211            &self.direct_file_count.to_string(),
212        );
213        push_field(
214            &mut out,
215            1,
216            "direct_directory_count",
217            &self.direct_directory_count.to_string(),
218        );
219        push_field(
220            &mut out,
221            1,
222            "total_file_count",
223            &self.total_file_count.to_string(),
224        );
225        push_field(
226            &mut out,
227            1,
228            "total_directory_count",
229            &self.total_directory_count.to_string(),
230        );
231        push_field(
232            &mut out,
233            1,
234            "total_line_count",
235            &self.total_line_count.to_string(),
236        );
237        push_field(
238            &mut out,
239            1,
240            "total_byte_count",
241            &self.total_byte_count.to_string(),
242        );
243        for child in &self.children {
244            match child {
245                RepositoryResourceFormalization::File(file) => {
246                    push_field(&mut out, 1, "file", &file.path);
247                }
248                RepositoryResourceFormalization::Directory(directory) => {
249                    push_field(&mut out, 1, "directory", &directory.path);
250                }
251            }
252        }
253        out.trim_end().to_owned()
254    }
255}
256
257/// Formalize an arbitrary repository resource (file or directory) recursively.
258#[must_use]
259pub fn formalize_repository_resource(entry: &RepositoryEntry) -> RepositoryResourceFormalization {
260    match entry {
261        RepositoryEntry::File { path, content } => {
262            RepositoryResourceFormalization::File(formalize_repository_file(path, content))
263        }
264        RepositoryEntry::Directory { path, children } => {
265            RepositoryResourceFormalization::Directory(formalize_repository_directory(
266                path, children,
267            ))
268        }
269    }
270}
271
272/// Formalize a repository directory from its path and ordered child entries.
273#[must_use]
274pub fn formalize_repository_directory(
275    path: &str,
276    children: &[RepositoryEntry],
277) -> RepositoryDirectoryFormalization {
278    let formalized_children: Vec<RepositoryResourceFormalization> =
279        children.iter().map(formalize_repository_resource).collect();
280
281    let mut direct_file_count = 0;
282    let mut direct_directory_count = 0;
283    let mut total_file_count = 0;
284    let mut total_directory_count = 0;
285    let mut total_line_count = 0;
286    let mut total_byte_count = 0;
287
288    for child in &formalized_children {
289        match child {
290            RepositoryResourceFormalization::File(file) => {
291                direct_file_count += 1;
292                total_file_count += 1;
293                total_line_count += file.line_count;
294                total_byte_count += file.byte_count;
295            }
296            RepositoryResourceFormalization::Directory(directory) => {
297                direct_directory_count += 1;
298                total_directory_count += 1 + directory.total_directory_count;
299                total_file_count += directory.total_file_count;
300                total_line_count += directory.total_line_count;
301                total_byte_count += directory.total_byte_count;
302            }
303        }
304    }
305
306    RepositoryDirectoryFormalization {
307        path: path.to_owned(),
308        direct_file_count,
309        direct_directory_count,
310        total_file_count,
311        total_directory_count,
312        total_line_count,
313        total_byte_count,
314        children: formalized_children,
315    }
316}
317
318/// Summarize any repository resource (file or directory) with the supplied
319/// configuration. This is the general entry point that subsumes
320/// [`super::file::summarize_repository_file`].
321#[must_use]
322pub fn summarize_repository_resource(
323    entry: &RepositoryEntry,
324    config: &SummarizationConfig,
325) -> String {
326    formalize_repository_resource(entry).summary(config)
327}
328
329/// How many direct children a directory summary lists in prose, per mode. Deeper
330/// detail is reached by recursion at a shorter mode, not by listing more
331/// children, so this stays small enough to keep summaries readable.
332const fn child_summary_cap(mode: SummarizationMode) -> usize {
333    match mode {
334        SummarizationMode::Topic => 0,
335        SummarizationMode::Short => 2,
336        SummarizationMode::Standard => 4,
337        SummarizationMode::Full | SummarizationMode::Expand => usize::MAX,
338    }
339}
340
341const fn pluralize(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
342    if count == 1 {
343        singular
344    } else {
345        plural
346    }
347}
348
349fn push_field(out: &mut String, indent: usize, name: &str, value: &str) {
350    for _ in 0..indent {
351        out.push_str("  ");
352    }
353    let _ = writeln!(out, "{name} {}", flatten_lino_value(value));
354}