scribe_selection/
bundler.rs

1//! Code bundler module responsible for turning a selection context into
2//! consumable artifacts. The implementation focuses on two lightweight
3//! formats (JSON and plain-text) to unblock the web service and CLI.
4
5use crate::context::CodeContext;
6use scribe_core::Result;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct BundleOptions {
13    /// Output format (`json` or `plain`). Defaults to JSON.
14    pub format: String,
15    /// Whether to include metadata describing the produced bundle.
16    pub include_metadata: bool,
17}
18
19impl Default for BundleOptions {
20    fn default() -> Self {
21        Self {
22            format: "json".to_string(),
23            include_metadata: true,
24        }
25    }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CodeBundle {
30    /// Serialized bundle in the requested format.
31    pub content: String,
32    /// Additional metadata describing the bundle.
33    pub metadata: HashMap<String, String>,
34}
35
36pub struct CodeBundler;
37
38impl CodeBundler {
39    pub fn new() -> Self {
40        Self
41    }
42
43    pub async fn bundle(
44        &self,
45        context: &CodeContext,
46        options: &BundleOptions,
47    ) -> Result<CodeBundle> {
48        let content = match options.format.as_str() {
49            "plain" => render_plain(context),
50            _ => render_json(context)?,
51        };
52
53        let metadata = if options.include_metadata {
54            build_metadata(context, options)
55        } else {
56            HashMap::new()
57        };
58
59        Ok(CodeBundle { content, metadata })
60    }
61}
62
63impl Default for CodeBundler {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69fn render_json(context: &CodeContext) -> Result<String> {
70    let files: Vec<_> = context
71        .files
72        .iter()
73        .map(|file| {
74            json!({
75                "path": file.path,
76                "token_estimate": file.token_estimate,
77                "contents": file.contents,
78            })
79        })
80        .collect();
81
82    Ok(serde_json::to_string_pretty(&json!({
83        "total_tokens": context.total_tokens,
84        "files": files,
85    }))?)
86}
87
88fn render_plain(context: &CodeContext) -> String {
89    let mut body = String::new();
90
91    for file in &context.files {
92        body.push_str("===== ");
93        body.push_str(&file.path);
94        body.push_str(" =====\n");
95
96        match &file.contents {
97            Some(contents) => body.push_str(contents),
98            None => body.push_str("[content not loaded]\n"),
99        }
100
101        body.push_str("\n\n");
102    }
103
104    body
105}
106
107fn build_metadata(context: &CodeContext, options: &BundleOptions) -> HashMap<String, String> {
108    let mut metadata = HashMap::new();
109    metadata.insert("format".to_string(), options.format.clone());
110    metadata.insert("file_count".to_string(), context.files.len().to_string());
111    metadata.insert("total_tokens".to_string(), context.total_tokens.to_string());
112    metadata
113}