Skip to main content

systemprompt_cli/commands/analytics/shared/
export.rs

1//! CSV export building with field escaping.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::{Context, Result};
7use serde::Serialize;
8use std::fs::{self, File};
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use systemprompt_config::ProfileBootstrap;
13use systemprompt_loader::ServicesRootBootstrap;
14use systemprompt_models::AppPaths;
15
16pub fn resolve_export_path(user_path: &Path) -> Result<PathBuf> {
17    if user_path.is_absolute()
18        || user_path
19            .parent()
20            .is_some_and(|p| !p.as_os_str().is_empty())
21    {
22        return Ok(user_path.to_path_buf());
23    }
24
25    let profile = ProfileBootstrap::get().context("Profile not initialized")?;
26    let paths = AppPaths::from_profile(
27        &profile.paths,
28        profile.path_resolution(),
29        ServicesRootBootstrap::get().map(|r| r.path.as_path()),
30    )
31    .map_err(|e| anyhow::anyhow!("Failed to build paths: {}", e))?;
32    let exports_dir = paths.storage().exports().to_path_buf();
33
34    Ok(exports_dir.join(user_path))
35}
36
37pub fn ensure_export_dir(path: &Path) -> Result<()> {
38    if let Some(parent) = path.parent()
39        && !parent.exists()
40    {
41        fs::create_dir_all(parent).context("Failed to create export directory")?;
42    }
43    Ok(())
44}
45
46pub fn export_to_csv<T: Serialize>(data: &[T], path: &Path) -> Result<()> {
47    ensure_export_dir(path)?;
48    let file = File::create(path).context("Failed to create export file")?;
49    let mut writer = std::io::BufWriter::new(file);
50
51    if data.is_empty() {
52        return Ok(());
53    }
54
55    let json_value = serde_json::to_value(&data[0])?;
56    if let serde_json::Value::Object(obj) = json_value {
57        let headers: Vec<&str> = obj.keys().map(String::as_str).collect();
58        writeln!(writer, "{}", headers.join(","))?;
59    }
60
61    for record in data {
62        let json = serde_json::to_value(record)?;
63        if let serde_json::Value::Object(obj) = json {
64            let values: Vec<String> = obj
65                .values()
66                .map(|v| match v {
67                    serde_json::Value::String(s) => escape_csv_field(s),
68                    serde_json::Value::Null => String::new(),
69                    _ => v.to_string(),
70                })
71                .collect();
72            writeln!(writer, "{}", values.join(","))?;
73        }
74    }
75
76    writer.flush()?;
77    Ok(())
78}
79
80pub fn export_single_to_csv<T: Serialize>(data: &T, path: &Path) -> Result<()> {
81    ensure_export_dir(path)?;
82    let file = File::create(path).context("Failed to create export file")?;
83    let mut writer = std::io::BufWriter::new(file);
84
85    let json = serde_json::to_value(data)?;
86    if let serde_json::Value::Object(obj) = json {
87        let headers: Vec<&str> = obj.keys().map(String::as_str).collect();
88        writeln!(writer, "{}", headers.join(","))?;
89
90        let values: Vec<String> = obj
91            .values()
92            .map(|v| match v {
93                serde_json::Value::String(s) => escape_csv_field(s),
94                serde_json::Value::Null => String::new(),
95                _ => v.to_string(),
96            })
97            .collect();
98        writeln!(writer, "{}", values.join(","))?;
99    }
100
101    writer.flush()?;
102    Ok(())
103}
104
105fn escape_csv_field(s: &str) -> String {
106    if s.contains(',') || s.contains('"') || s.contains('\n') {
107        format!("\"{}\"", s.replace('"', "\"\""))
108    } else {
109        s.to_owned()
110    }
111}
112
113#[derive(Debug)]
114pub struct CsvBuilder {
115    headers: Vec<String>,
116    rows: Vec<Vec<String>>,
117}
118
119impl CsvBuilder {
120    pub const fn new() -> Self {
121        Self {
122            headers: Vec::new(),
123            rows: Vec::new(),
124        }
125    }
126
127    pub fn headers(mut self, headers: Vec<&str>) -> Self {
128        self.headers = headers.into_iter().map(String::from).collect();
129        self
130    }
131
132    pub fn add_row(&mut self, row: Vec<String>) {
133        self.rows.push(row);
134    }
135
136    pub fn write_to_file(&self, path: &Path) -> Result<()> {
137        ensure_export_dir(path)?;
138        let mut file = File::create(path).context("Failed to create export file")?;
139
140        writeln!(file, "{}", self.headers.join(","))?;
141
142        for row in &self.rows {
143            let escaped: Vec<String> = row.iter().map(|cell| escape_csv_field(cell)).collect();
144            writeln!(file, "{}", escaped.join(","))?;
145        }
146
147        file.flush().context("Failed to flush export file")?;
148        Ok(())
149    }
150}
151
152impl Default for CsvBuilder {
153    fn default() -> Self {
154        Self::new()
155    }
156}