Skip to main content

spreadsheet_kit/cli/
errors.rs

1use crate::cli::OutputFormat;
2use crate::model::{FORMULA_PARSE_FAILED, FORMULA_PARSE_FAILED_PREFIX};
3use anyhow::{Result, bail};
4use serde::Serialize;
5
6pub fn ensure_output_supported(format: OutputFormat) -> Result<()> {
7    match format {
8        OutputFormat::Json => Ok(()),
9        OutputFormat::Csv => {
10            bail!("csv output is not implemented yet for this CLI; use --output-format json")
11        }
12    }
13}
14
15#[derive(Debug, Serialize)]
16pub struct ErrorEnvelope {
17    pub code: String,
18    pub message: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub did_you_mean: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub try_this: Option<String>,
23}
24
25pub fn envelope_for(error: &anyhow::Error) -> ErrorEnvelope {
26    let message = error.to_string();
27
28    if let Some((requested, suggested)) = parse_sheet_suggestion(&message) {
29        return ErrorEnvelope {
30            code: "SHEET_NOT_FOUND".to_string(),
31            message: format!("sheet '{}' was not found", requested),
32            did_you_mean: Some(suggested),
33            try_this: Some("run `asp read sheets <file>` to inspect valid names".to_string()),
34        };
35    }
36
37    if let Some(requested) = parse_sheet_not_found(&message) {
38        return ErrorEnvelope {
39            code: "SHEET_NOT_FOUND".to_string(),
40            message: format!("sheet '{}' was not found", requested),
41            did_you_mean: None,
42            try_this: Some("run `asp read sheets <file>` to inspect valid names".to_string()),
43        };
44    }
45
46    if let Some(detail) = message.strip_prefix("invalid argument: ") {
47        let try_this = if detail.contains("session payload kind") {
48            "run `asp example session op transform.write_matrix` or `asp schema session op transform.write_matrix` to inspect supported kinds and canonical payloads".to_string()
49        } else {
50            "run the command with --help to inspect valid arguments".to_string()
51        };
52        return ErrorEnvelope {
53            code: "INVALID_ARGUMENT".to_string(),
54            message: detail.to_string(),
55            did_you_mean: None,
56            try_this: Some(try_this),
57        };
58    }
59
60    if let Some(detail) = message.strip_prefix("invalid ops payload: ") {
61        return ErrorEnvelope {
62            code: "INVALID_OPS_PAYLOAD".to_string(),
63            message: detail.to_string(),
64            did_you_mean: None,
65            try_this: Some("pass --ops @<path-to-json> with payload {\"ops\":[...]}".to_string()),
66        };
67    }
68
69    if message.starts_with("invalid session ops payload") {
70        return ErrorEnvelope {
71            code: "INVALID_OPS_PAYLOAD".to_string(),
72            message,
73            did_you_mean: None,
74            try_this: Some(
75                "run `asp example session op transform.write_matrix` or `asp schema session op transform.write_matrix` to inspect the canonical payload contract".to_string(),
76            ),
77        };
78    }
79
80    if let Some(detail) = message.strip_prefix("output exists: ") {
81        return ErrorEnvelope {
82            code: "OUTPUT_EXISTS".to_string(),
83            message: detail.to_string(),
84            did_you_mean: None,
85            try_this: Some("choose a new --output path or re-run with --force".to_string()),
86        };
87    }
88
89    if let Some(detail) = message.strip_prefix("unsafe clone template: ") {
90        return ErrorEnvelope {
91            code: "UNSAFE_CLONE_TEMPLATE".to_string(),
92            message: detail.to_string(),
93            did_you_mean: None,
94            try_this: Some(
95                "re-run with --merge-policy safe or choose a different template row".to_string(),
96            ),
97        };
98    }
99
100    if let Some(detail) = message.strip_prefix("write failed: ") {
101        return ErrorEnvelope {
102            code: "WRITE_FAILED".to_string(),
103            message: detail.to_string(),
104            did_you_mean: None,
105            try_this: Some("check destination permissions and available disk space".to_string()),
106        };
107    }
108
109    if message.contains("does not exist") {
110        return ErrorEnvelope {
111            code: "FILE_NOT_FOUND".to_string(),
112            message,
113            did_you_mean: None,
114            try_this: Some("check the workbook path and permissions".to_string()),
115        };
116    }
117
118    if message.contains("at least one range") {
119        return ErrorEnvelope {
120            code: "INVALID_ARGUMENT".to_string(),
121            message,
122            did_you_mean: None,
123            try_this: Some("pass one or more A1 ranges, for example: `A1:C10`".to_string()),
124        };
125    }
126
127    if message.contains("at least one edit") {
128        return ErrorEnvelope {
129            code: "INVALID_ARGUMENT".to_string(),
130            message,
131            did_you_mean: None,
132            try_this: Some("add one or more edits like `A1=42` or `B2==SUM(A1:A1)`".to_string()),
133        };
134    }
135
136    if message.contains("invalid shorthand edit") {
137        return ErrorEnvelope {
138            code: "INVALID_EDIT_SYNTAX".to_string(),
139            message,
140            did_you_mean: None,
141            try_this: Some(
142                "use `<cell>=<value>` for values or `<cell>==<formula>` for formulas".to_string(),
143            ),
144        };
145    }
146
147    if message.contains("csv output is not implemented") {
148        return ErrorEnvelope {
149            code: "OUTPUT_FORMAT_UNSUPPORTED".to_string(),
150            message,
151            did_you_mean: Some("json".to_string()),
152            try_this: Some("re-run with `--output-format json`".to_string()),
153        };
154    }
155
156    if message.starts_with(FORMULA_PARSE_FAILED_PREFIX) {
157        return ErrorEnvelope {
158            code: FORMULA_PARSE_FAILED.to_string(),
159            message,
160            did_you_mean: None,
161            try_this: Some(
162                "re-run with --formula-parse-policy warn to collect diagnostics instead of aborting"
163                    .to_string(),
164            ),
165        };
166    }
167
168    ErrorEnvelope {
169        code: "COMMAND_FAILED".to_string(),
170        message,
171        did_you_mean: None,
172        try_this: None,
173    }
174}
175
176fn parse_sheet_suggestion(message: &str) -> Option<(String, String)> {
177    let prefix = "sheet '";
178    let not_found = "' not found; did you mean '";
179    let suffix = "' ?";
180
181    let start = message.find(prefix)? + prefix.len();
182    let rest = &message[start..];
183    let mid = rest.find(not_found)?;
184    let requested = &rest[..mid];
185    let suggestion_start = start + mid + not_found.len();
186    let suggestion_rest = &message[suggestion_start..];
187    let suggestion_end = suggestion_rest.find(suffix)?;
188    let suggested = &suggestion_rest[..suggestion_end];
189    Some((requested.to_string(), suggested.to_string()))
190}
191
192fn parse_sheet_not_found(message: &str) -> Option<String> {
193    let rest = message.strip_prefix("sheet ")?;
194    if rest.contains(" not found; did you mean ") {
195        return None;
196    }
197    if let Some(stripped) = rest.strip_prefix('\'')
198        && let Some(requested) = stripped.strip_suffix("' not found")
199    {
200        return Some(requested.to_string());
201    }
202    rest.strip_suffix(" not found").map(str::to_string)
203}