1use std::path::PathBuf;
8use std::process::Command;
9
10pub async fn run_share(
15 state_dir: &std::path::Path,
16 include_demo_gif: bool,
17) -> anyhow::Result<()> {
18 println!("π Sparrow Share β partage ta session\n");
19
20 let transcripts_dir = state_dir.join("transcripts");
22 let latest = find_latest_transcript(&transcripts_dir)?;
23
24 println!("π Transcript : {}", latest.display());
25
26 let raw = std::fs::read_to_string(&latest)?;
28 let formatted = format_transcript(&raw, &latest, include_demo_gif)?;
29
30 let filename = latest
32 .file_name()
33 .map(|n| n.to_string_lossy().to_string())
34 .unwrap_or_else(|| "sparrow-session.md".into());
35
36 let url = upload_gist(&filename, &formatted).await?;
37
38 println!();
40 println!("ββββββββββββββββββββββββββββββββββββββββββββββββββ");
41 println!(" β
Gist créé avec succès !");
42 println!("ββββββββββββββββββββββββββββββββββββββββββββββββββ");
43 println!();
44 println!(" π {url}");
45 println!();
46 println!(" PartagΓ© depuis Sparrow β https://github.com/ucav/Sparrow");
47 println!();
48
49 Ok(())
50}
51
52fn find_latest_transcript(transcripts_dir: &std::path::Path) -> anyhow::Result<PathBuf> {
55 if !transcripts_dir.exists() {
56 anyhow::bail!(
57 "Aucun transcript trouvΓ©. Le dossier {} n'existe pas.\n\
58 β Lance une tΓ’che Sparrow d'abord : sparrow run \"explique ce projet\"",
59 transcripts_dir.display()
60 );
61 }
62
63 let mut entries: Vec<_> = std::fs::read_dir(transcripts_dir)?
64 .filter_map(|e| e.ok())
65 .filter(|e| {
66 e.path()
67 .extension()
68 .map(|ext| ext == "json" || ext == "jsonl")
69 .unwrap_or(false)
70 })
71 .collect();
72
73 if entries.is_empty() {
74 anyhow::bail!(
75 "Aucun transcript trouvΓ© dans {}.\n\
76 β Lance une tΓ’che Sparrow d'abord !",
77 transcripts_dir.display()
78 );
79 }
80
81 entries.sort_by(|a, b| {
83 b.metadata()
84 .and_then(|m| m.modified())
85 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
86 .cmp(
87 &a.metadata()
88 .and_then(|m| m.modified())
89 .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
90 )
91 });
92
93 Ok(entries[0].path())
94}
95
96fn format_transcript(
99 raw: &str,
100 path: &std::path::Path,
101 include_demo_gif: bool,
102) -> anyhow::Result<String> {
103 let timestamp = path
104 .metadata()
105 .ok()
106 .and_then(|m| m.modified().ok())
107 .map(|t| {
108 let dt: chrono::DateTime<chrono::Local> = t.into();
109 dt.format("%Y-%m-%d %H:%M:%S").to_string()
110 })
111 .unwrap_or_else(|| "date inconnue".into());
112
113 let mut md = String::new();
114
115 md.push_str(&format!(
117 "# π¦ Sparrow Session β {timestamp}\n\n"
118 ));
119
120 if include_demo_gif {
121 md.push_str("> π¬ *DΓ©mo GIF Γ venir β regarde Sparrow en action !*\n\n");
122 }
123
124 md.push_str(&format!(
125 "Session gΓ©nΓ©rΓ©e par [Sparrow](https://github.com/ucav/Sparrow) v{version}.\n\n",
126 version = env!("CARGO_PKG_VERSION"),
127 ));
128 md.push_str("---\n\n");
129
130 let parsed = if raw.trim().starts_with('{') {
132 format_single_json_transcript(raw)
134 } else if raw.trim().starts_with('[') {
135 format_json_array_transcript(raw)
137 } else {
138 format_ndjson_transcript(raw)
140 };
141
142 match parsed {
143 Ok(formatted) => md.push_str(&formatted),
144 Err(_) => {
145 md.push_str("## Transcript brut\n\n");
147 md.push_str("```json\n");
148 if raw.len() > 500_000 {
150 md.push_str(&raw[..500_000]);
151 md.push_str("\n\n// ... (transcript tronquΓ© β trop volumineux)\n");
152 } else {
153 md.push_str(raw);
154 }
155 md.push_str("\n```\n");
156 }
157 }
158
159 md.push_str("\n---\n");
161 md.push_str("*PartagΓ© avec Sparrow β l'agent CLI qui code avec toi.*\n");
162 md.push_str(
163 "*[Installer Sparrow](https://github.com/ucav/Sparrow) | cargo install sparrow*\n",
164 );
165
166 Ok(md)
167}
168
169fn format_single_json_transcript(raw: &str) -> anyhow::Result<String> {
171 let v: serde_json::Value = serde_json::from_str(raw)?;
172 let mut md = String::new();
173
174 if let Some(task) = v.get("task").and_then(|t| t.as_str()) {
175 md.push_str(&format!("## π TΓ’che\n\n{task}\n\n"));
176 }
177
178 if let Some(events) = v.get("events").and_then(|e| e.as_array()) {
179 md.push_str("## π ΓvΓ©nements\n\n");
180 for event in events {
181 render_event_to_markdown(&mut md, event);
182 }
183 }
184
185 if let Some(usage) = v.get("usage") {
186 md.push_str("## π Statistiques\n\n");
187 if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) {
188 md.push_str(&format!("- **Tokens d'entrΓ©e** : {input}\n"));
189 }
190 if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) {
191 md.push_str(&format!("- **Tokens de sortie** : {output}\n"));
192 }
193 if let Some(cost) = usage.get("cost_usd").and_then(|c| c.as_f64()) {
194 md.push_str(&format!("- **CoΓ»t estimΓ©** : ${cost:.4}\n"));
195 }
196 md.push('\n');
197 }
198
199 Ok(md)
200}
201
202fn format_json_array_transcript(raw: &str) -> anyhow::Result<String> {
204 let events: Vec<serde_json::Value> = serde_json::from_str(raw)?;
205 let mut md = String::from("## π ΓvΓ©nements\n\n");
206
207 for event in &events {
208 render_event_to_markdown(&mut md, event);
209 }
210
211 Ok(md)
212}
213
214fn format_ndjson_transcript(raw: &str) -> anyhow::Result<String> {
216 let mut md = String::from("## π ΓvΓ©nements\n\n");
217 let mut task_found = false;
218
219 for line in raw.lines() {
220 let trimmed = line.trim();
221 if trimmed.is_empty() {
222 continue;
223 }
224
225 if let Ok(event) = serde_json::from_str::<serde_json::Value>(trimmed) {
226 if !task_found {
228 if let Some(task) = event
229 .get("task")
230 .or_else(|| event.get("description"))
231 .and_then(|t| t.as_str())
232 {
233 md.push_str(&format!("## π TΓ’che\n\n{task}\n\n"));
234 task_found = true;
235 }
236 }
237 render_event_to_markdown(&mut md, &event);
238 }
239 }
240
241 if md == "## π ΓvΓ©nements\n\n" {
242 md.push_str("*Aucun Γ©vΓ©nement parsable trouvΓ©.*\n\n");
243 }
244
245 Ok(md)
246}
247
248fn render_event_to_markdown(md: &mut String, event: &serde_json::Value) {
250 let event_type = event
251 .get("type")
252 .or_else(|| event.get("event"))
253 .and_then(|t| t.as_str())
254 .unwrap_or("event");
255
256 match event_type {
257 "RunStarted" | "run_started" => {
258 let task = event
259 .get("task")
260 .and_then(|t| t.as_str())
261 .unwrap_or("(tΓ’che)");
262 md.push_str(&format!("### π DΓ©marrage : {task}\n\n"));
263 }
264 "TextDelta" | "text_delta" | "assistant" => {
265 if let Some(text) = event.get("text").or_else(|| event.get("content")).and_then(|t| t.as_str()) {
266 if text.len() > 200 {
267 md.push_str(&format!(
268 "<details>\n<summary>π¬ {}</summary>\n\n{}\n\n</details>\n\n",
269 &text[..text.len().min(80)],
270 text,
271 ));
272 } else {
273 md.push_str(&format!("π¬ {text}\n\n"));
274 }
275 }
276 }
277 "ToolUseProposed" | "tool_use" => {
278 let name = event
279 .get("name")
280 .or_else(|| event.get("tool"))
281 .and_then(|n| n.as_str())
282 .unwrap_or("?");
283 let input = event
284 .get("input")
285 .or_else(|| event.get("args"))
286 .map(|i| format!("{i}"))
287 .unwrap_or_default();
288 md.push_str(&format!(
289 "<details>\n<summary>π§ Outil : `{name}`</summary>\n\n```json\n{input}\n```\n\n</details>\n\n"
290 ));
291 }
292 "ToolOutput" | "tool_output" | "tool_result" => {
293 let output = event
294 .get("output")
295 .or_else(|| event.get("content"))
296 .or_else(|| event.get("result"))
297 .map(|o| format!("{o}"))
298 .unwrap_or_default();
299 let truncated = if output.len() > 500 {
300 format!("{}...", &output[..500])
301 } else {
302 output
303 };
304 md.push_str(&format!("β
RΓ©sultat :\n```\n{truncated}\n```\n\n"));
305 }
306 "Error" | "error" => {
307 let msg = event
308 .get("message")
309 .or_else(|| event.get("error"))
310 .and_then(|m| m.as_str())
311 .unwrap_or("erreur inconnue");
312 md.push_str(&format!("β **Erreur** : {msg}\n\n"));
313 }
314 "RunFinished" | "run_finished" | "Done" => {
315 let reason = event
316 .get("reason")
317 .or_else(|| event.get("stop_reason"))
318 .and_then(|r| r.as_str())
319 .unwrap_or("terminΓ©");
320 md.push_str(&format!("### π TerminΓ© ({reason})\n\n"));
321 }
322 _ => {
323 let pretty = serde_json::to_string_pretty(event).unwrap_or_default();
325 if pretty.len() < 200 {
326 md.push_str(&format!("π `{event_type}` :\n```json\n{pretty}\n```\n\n"));
327 }
328 }
329 }
330}
331
332async fn upload_gist(filename: &str, content: &str) -> anyhow::Result<String> {
339 if gh_available() && gh_authenticated() {
341 match upload_via_gh_cli(filename, content) {
342 Ok(url) => return Ok(url),
343 Err(e) => {
344 eprintln!("β οΈ Γchec gh CLI : {e}");
345 eprintln!(" β Tentative via API directe...");
346 }
347 }
348 }
349
350 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
352 if !token.is_empty() {
353 return upload_via_api(filename, content, &token).await;
354 }
355 }
356
357 anyhow::bail!(
359 "Impossible de crΓ©er un Gist.\n\
360 β Installe gh CLI : https://cli.github.com\n\
361 β Puis : gh auth login\n\
362 β Ou dΓ©finis GITHUB_TOKEN : export GITHUB_TOKEN=\"ghp_...\""
363 )
364}
365
366fn gh_available() -> bool {
367 Command::new("gh")
368 .arg("--version")
369 .stdout(std::process::Stdio::null())
370 .stderr(std::process::Stdio::null())
371 .status()
372 .map(|s| s.success())
373 .unwrap_or(false)
374}
375
376fn gh_authenticated() -> bool {
377 Command::new("gh")
378 .args(["auth", "status"])
379 .stdout(std::process::Stdio::null())
380 .stderr(std::process::Stdio::null())
381 .status()
382 .map(|s| s.success())
383 .unwrap_or(false)
384}
385
386fn upload_via_gh_cli(filename: &str, content: &str) -> anyhow::Result<String> {
387 let tmp = std::env::temp_dir().join(format!("sparrow-gist-{}.md", uuid::Uuid::new_v4()));
389 std::fs::write(&tmp, content)?;
390
391 let output = Command::new("gh")
392 .args([
393 "gist",
394 "create",
395 "--public",
396 "--filename",
397 filename,
398 "--desc",
399 &format!(
400 "π¦ Sparrow Session β shared via sparrow share (v{})",
401 env!("CARGO_PKG_VERSION")
402 ),
403 ])
404 .arg(&tmp)
405 .output()?;
406
407 let _ = std::fs::remove_file(&tmp);
409
410 if !output.status.success() {
411 let stderr = String::from_utf8_lossy(&output.stderr);
412 anyhow::bail!("gh gist create a Γ©chouΓ© : {stderr}");
413 }
414
415 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
416 if url.is_empty() {
417 anyhow::bail!("gh gist create n'a pas retournΓ© d'URL.");
418 }
419
420 Ok(url)
421}
422
423async fn upload_via_api(
424 filename: &str,
425 content: &str,
426 token: &str,
427) -> anyhow::Result<String> {
428 let client = reqwest::Client::builder()
429 .user_agent("sparrow-share")
430 .timeout(std::time::Duration::from_secs(30))
431 .build()?;
432
433 let body = serde_json::json!({
434 "description": format!("π¦ Sparrow Session β shared via sparrow share (v{})", env!("CARGO_PKG_VERSION")),
435 "public": true,
436 "files": {
437 filename: {
438 "content": content
439 }
440 }
441 });
442
443 let resp = client
444 .post("https://api.github.com/gists")
445 .header("Authorization", format!("Bearer {token}"))
446 .header("Accept", "application/vnd.github+json")
447 .header("X-GitHub-Api-Version", "2022-11-28")
448 .json(&body)
449 .send()
450 .await?;
451
452 let status = resp.status();
453 let resp_body = resp.text().await?;
454
455 if !status.is_success() {
456 anyhow::bail!(
457 "GitHub API a retournΓ© HTTP {} : {}",
458 status.as_u16(),
459 resp_body,
460 );
461 }
462
463 let parsed: serde_json::Value = serde_json::from_str(&resp_body)?;
464 let html_url = parsed
465 .get("html_url")
466 .and_then(|u| u.as_str())
467 .ok_or_else(|| anyhow::anyhow!("RΓ©ponse GitHub inattendue : pas de html_url"))?;
468
469 Ok(html_url.to_string())
470}