1use std::io;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6
7use futures::future::BoxFuture;
8#[cfg(test)]
9use std::collections::VecDeque;
10#[cfg(test)]
11use std::sync::Mutex;
12use thiserror::Error;
13use tokio::process::Command;
14use tokio_util::sync::CancellationToken;
15use uuid::Uuid;
16
17use super::config::get_share_viewer_url;
18use super::export_html::{ExportError, ExportOptions, SessionExportState, export_session_to_html};
19use super::sessions::SessionManager;
20
21#[derive(Clone, Debug, Default, Eq, PartialEq)]
23pub struct CommandOutput {
24 pub status: Option<i32>,
26 pub stdout: String,
28 pub stderr: String,
30}
31
32impl CommandOutput {
33 fn success(&self) -> bool {
34 self.status == Some(0)
35 }
36}
37
38#[derive(Debug, Error)]
40pub enum CommandRunError {
41 #[error("command not found: {0}")]
43 NotFound(String),
44 #[error(transparent)]
46 Io(#[from] io::Error),
47 #[error("command cancelled")]
49 Cancelled,
50}
51
52pub trait CommandRunner: Send + Sync {
54 fn run<'a>(
56 &'a self,
57 program: &'a str,
58 arguments: &'a [String],
59 cancellation: &'a CancellationToken,
60 ) -> BoxFuture<'a, Result<CommandOutput, CommandRunError>>;
61}
62
63#[derive(Clone, Copy, Debug, Default)]
65pub struct SystemCommandRunner;
66
67impl CommandRunner for SystemCommandRunner {
68 fn run<'a>(
69 &'a self,
70 program: &'a str,
71 arguments: &'a [String],
72 cancellation: &'a CancellationToken,
73 ) -> BoxFuture<'a, Result<CommandOutput, CommandRunError>> {
74 Box::pin(async move {
75 if cancellation.is_cancelled() {
76 return Err(CommandRunError::Cancelled);
77 }
78 let mut command = Command::new(program);
79 command
80 .args(arguments)
81 .stdin(Stdio::null())
82 .stdout(Stdio::piped())
83 .stderr(Stdio::piped())
84 .kill_on_drop(true);
85 let child = command.spawn().map_err(|error| {
86 if error.kind() == io::ErrorKind::NotFound {
87 CommandRunError::NotFound(program.to_owned())
88 } else {
89 CommandRunError::Io(error)
90 }
91 })?;
92 let wait = child.wait_with_output();
93 tokio::pin!(wait);
94 let output = tokio::select! {
95 biased;
96 () = cancellation.cancelled() => return Err(CommandRunError::Cancelled),
97 result = &mut wait => result.map_err(CommandRunError::Io)?,
98 };
99 Ok(CommandOutput {
100 status: output.status.code(),
101 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
102 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
103 })
104 })
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct ShareResult {
111 pub viewer_url: String,
113 pub gist_url: String,
115}
116
117impl ShareResult {
118 #[must_use]
120 pub fn status_text(&self) -> String {
121 format!("Share URL: {}\nGist: {}", self.viewer_url, self.gist_url)
122 }
123}
124
125#[derive(Debug, Error)]
127pub enum ShareError {
128 #[error("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")]
130 GhNotInstalled,
131 #[error("GitHub CLI is not logged in. Run 'gh auth login' first.")]
133 GhNotLoggedIn,
134 #[error("Failed to export session: {0}")]
136 Export(#[from] ExportError),
137 #[error("Failed to create gist: {0}")]
139 GistCreateFailed(String),
140 #[error("Failed to parse gist ID from gh output")]
142 GistIdParseFailed,
143 #[error("Share cancelled")]
145 Cancelled,
146 #[error("Failed to create gist: {0}")]
148 Io(String),
149}
150
151impl From<CommandRunError> for ShareError {
152 fn from(error: CommandRunError) -> Self {
153 match error {
154 CommandRunError::NotFound(_) => Self::GhNotInstalled,
155 CommandRunError::Cancelled => Self::Cancelled,
156 CommandRunError::Io(error) => Self::Io(error.to_string()),
157 }
158 }
159}
160
161pub async fn check_gh_auth_with(
167 runner: &dyn CommandRunner,
168 cancellation: &CancellationToken,
169) -> Result<(), ShareError> {
170 let arguments = ["auth".to_owned(), "status".to_owned()];
171 let result = runner.run("gh", &arguments, cancellation).await?;
172 if result.success() {
173 Ok(())
174 } else {
175 Err(ShareError::GhNotLoggedIn)
176 }
177}
178
179pub async fn check_gh_auth(cancellation: &CancellationToken) -> Result<(), ShareError> {
185 check_gh_auth_with(&SystemCommandRunner, cancellation).await
186}
187
188fn gist_url_from_stdout(stdout: &str) -> Option<&str> {
189 stdout
190 .lines()
191 .rev()
192 .map(str::trim)
193 .find(|line| !line.is_empty())
194}
195
196pub async fn share_html_file_with(
202 html_path: &Path,
203 runner: &dyn CommandRunner,
204 cancellation: &CancellationToken,
205) -> Result<ShareResult, ShareError> {
206 check_gh_auth_with(runner, cancellation).await?;
207 let arguments = vec![
208 "gist".to_owned(),
209 "create".to_owned(),
210 "--public=false".to_owned(),
211 html_path.to_string_lossy().into_owned(),
212 ];
213 let output = runner.run("gh", &arguments, cancellation).await?;
214 if !output.success() {
215 let message = output.stderr.trim();
216 return Err(ShareError::GistCreateFailed(if message.is_empty() {
217 "Unknown error".to_owned()
218 } else {
219 message.to_owned()
220 }));
221 }
222 let gist_url = gist_url_from_stdout(&output.stdout)
223 .ok_or(ShareError::GistIdParseFailed)?
224 .to_owned();
225 let gist_id = gist_url
226 .rsplit('/')
227 .next()
228 .filter(|segment| !segment.is_empty())
229 .ok_or(ShareError::GistIdParseFailed)?;
230 Ok(ShareResult {
231 viewer_url: get_share_viewer_url(gist_id),
232 gist_url,
233 })
234}
235
236pub async fn share_html_file(
242 html_path: &Path,
243 cancellation: &CancellationToken,
244) -> Result<ShareResult, ShareError> {
245 share_html_file_with(html_path, &SystemCommandRunner, cancellation).await
246}
247
248struct TemporaryShareFile {
249 directory: PathBuf,
250 html: PathBuf,
251}
252
253impl TemporaryShareFile {
254 fn create() -> Result<Self, ShareError> {
255 let directory = std::env::temp_dir().join(format!("pi-share-{}", Uuid::new_v4()));
256 std::fs::create_dir(&directory).map_err(|error| ShareError::Io(error.to_string()))?;
257 Ok(Self {
258 html: directory.join("session.html"),
259 directory,
260 })
261 }
262}
263
264impl Drop for TemporaryShareFile {
265 fn drop(&mut self) {
266 let _ = std::fs::remove_file(&self.html);
267 let _ = std::fs::remove_dir(&self.directory);
268 }
269}
270
271pub async fn share_session_with(
280 session: &SessionManager,
281 state: Option<&SessionExportState>,
282 runner: &dyn CommandRunner,
283 cancellation: &CancellationToken,
284) -> Result<ShareResult, ShareError> {
285 check_gh_auth_with(runner, cancellation).await?;
286 let temporary = TemporaryShareFile::create()?;
287 export_session_to_html(
288 session,
289 state,
290 ExportOptions {
291 output_path: Some(temporary.html.clone()),
292 ..ExportOptions::default()
293 },
294 )?;
295
296 let arguments = vec![
297 "gist".to_owned(),
298 "create".to_owned(),
299 "--public=false".to_owned(),
300 temporary.html.to_string_lossy().into_owned(),
301 ];
302 let output = runner.run("gh", &arguments, cancellation).await?;
303 if !output.success() {
304 let message = output.stderr.trim();
305 return Err(ShareError::GistCreateFailed(if message.is_empty() {
306 "Unknown error".to_owned()
307 } else {
308 message.to_owned()
309 }));
310 }
311 let gist_url = gist_url_from_stdout(&output.stdout)
312 .ok_or(ShareError::GistIdParseFailed)?
313 .to_owned();
314 let gist_id = gist_url
315 .rsplit('/')
316 .next()
317 .filter(|segment| !segment.is_empty())
318 .ok_or(ShareError::GistIdParseFailed)?;
319 Ok(ShareResult {
320 viewer_url: get_share_viewer_url(gist_id),
321 gist_url,
322 })
323}
324
325pub async fn share_session(
331 session: &SessionManager,
332 state: Option<&SessionExportState>,
333 cancellation: &CancellationToken,
334) -> Result<ShareResult, ShareError> {
335 share_session_with(session, state, &SystemCommandRunner, cancellation).await
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::sync::Arc;
342 use tempfile::tempdir;
343
344 type RecordedCall = (String, Vec<String>);
345 #[derive(Clone, Debug)]
346 enum FakeResponse {
347 Output(CommandOutput),
348 NotFound,
349 WaitForCancellation,
350 }
351
352 #[derive(Clone, Default)]
353 struct FakeRunner {
354 responses: Arc<Mutex<VecDeque<FakeResponse>>>,
355 calls: Arc<Mutex<Vec<RecordedCall>>>,
356 gist_file_seen: Arc<Mutex<Option<PathBuf>>>,
357 }
358
359 impl FakeRunner {
360 fn new(responses: impl IntoIterator<Item = FakeResponse>) -> Self {
361 Self {
362 responses: Arc::new(Mutex::new(responses.into_iter().collect())),
363 ..Self::default()
364 }
365 }
366
367 fn calls(&self) -> Vec<RecordedCall> {
368 self.calls
369 .lock()
370 .unwrap_or_else(std::sync::PoisonError::into_inner)
371 .clone()
372 }
373
374 fn gist_file(&self) -> Option<PathBuf> {
375 self.gist_file_seen
376 .lock()
377 .unwrap_or_else(std::sync::PoisonError::into_inner)
378 .clone()
379 }
380 }
381
382 impl CommandRunner for FakeRunner {
383 fn run<'a>(
384 &'a self,
385 program: &'a str,
386 arguments: &'a [String],
387 cancellation: &'a CancellationToken,
388 ) -> BoxFuture<'a, Result<CommandOutput, CommandRunError>> {
389 Box::pin(async move {
390 self.calls
391 .lock()
392 .unwrap_or_else(std::sync::PoisonError::into_inner)
393 .push((program.to_owned(), arguments.to_vec()));
394 if arguments.first().is_some_and(|value| value == "gist")
395 && let Some(path) = arguments.get(3)
396 {
397 *self
398 .gist_file_seen
399 .lock()
400 .unwrap_or_else(std::sync::PoisonError::into_inner) =
401 Some(PathBuf::from(path));
402 if !Path::new(path).exists() {
403 return Err(CommandRunError::Io(io::Error::new(
404 io::ErrorKind::NotFound,
405 "temporary HTML missing",
406 )));
407 }
408 }
409 let response = self
410 .responses
411 .lock()
412 .unwrap_or_else(std::sync::PoisonError::into_inner)
413 .pop_front()
414 .ok_or_else(|| {
415 CommandRunError::Io(io::Error::new(
416 io::ErrorKind::UnexpectedEof,
417 "no fake response",
418 ))
419 })?;
420 match response {
421 FakeResponse::Output(output) => Ok(output),
422 FakeResponse::NotFound => Err(CommandRunError::NotFound(program.to_owned())),
423 FakeResponse::WaitForCancellation => {
424 cancellation.cancelled().await;
425 Err(CommandRunError::Cancelled)
426 }
427 }
428 })
429 }
430 }
431
432 fn ok(stdout: &str) -> FakeResponse {
433 FakeResponse::Output(CommandOutput {
434 status: Some(0),
435 stdout: stdout.to_owned(),
436 stderr: String::new(),
437 })
438 }
439
440 fn persisted_session(root: &Path) -> Result<SessionManager, Box<dyn std::error::Error>> {
441 let path = root.join("source.jsonl");
442 std::fs::write(
443 &path,
444 concat!(
445 "{\"type\":\"session\",\"version\":3,\"id\":\"share\",\"timestamp\":\"2026-01-01T00:00:00.000Z\",\"cwd\":\"/tmp\"}\n",
446 "{\"type\":\"message\",\"id\":\"entry\",\"parentId\":null,\"timestamp\":\"2026-01-01T00:00:01.000Z\",\"message\":{\"role\":\"user\",\"content\":\"hello\",\"timestamp\":1}}\n"
447 ),
448 )?;
449 Ok(SessionManager::open(
450 &path.to_string_lossy(),
451 Some(&root.to_string_lossy()),
452 None,
453 )?)
454 }
455
456 #[tokio::test]
457 async fn distinguishes_missing_gh_and_failed_auth() -> Result<(), Box<dyn std::error::Error>> {
458 let cancellation = CancellationToken::new();
459 let missing = FakeRunner::new([FakeResponse::NotFound]);
460 let error = check_gh_auth_with(&missing, &cancellation)
461 .await
462 .err()
463 .ok_or("expected missing-gh error")?;
464 assert_eq!(
465 error.to_string(),
466 "GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"
467 );
468
469 let unauthenticated = FakeRunner::new([FakeResponse::Output(CommandOutput {
470 status: Some(1),
471 stdout: String::new(),
472 stderr: "not logged in".to_owned(),
473 })]);
474 let error = check_gh_auth_with(&unauthenticated, &cancellation)
475 .await
476 .err()
477 .ok_or("expected auth error")?;
478 assert_eq!(
479 error.to_string(),
480 "GitHub CLI is not logged in. Run 'gh auth login' first."
481 );
482 Ok(())
483 }
484
485 #[tokio::test]
486 async fn parses_last_url_and_uses_exact_private_gist_argv()
487 -> Result<(), Box<dyn std::error::Error>> {
488 let root = tempdir()?;
489 let html = root.path().join("session.html");
490 std::fs::write(&html, "html")?;
491 let runner = FakeRunner::new([
492 ok(""),
493 ok("warning\nhttps://gist.github.com/user/gist-id\n"),
494 ]);
495 let result = share_html_file_with(&html, &runner, &CancellationToken::new()).await?;
496 assert_eq!(result.viewer_url, "https://pi.dev/session/#gist-id");
497 assert_eq!(result.gist_url, "https://gist.github.com/user/gist-id");
498 assert_eq!(
499 result.status_text(),
500 concat!(
501 "Share URL: https://pi.dev/session/#gist-id\n",
502 "Gist: https://gist.github.com/user/gist-id"
503 )
504 );
505 let calls = runner.calls();
506 assert_eq!(calls[0].1, ["auth", "status"]);
507 assert_eq!(calls[1].1[..3], ["gist", "create", "--public=false"]);
508 assert_eq!(
509 calls[1].1.get(3),
510 Some(&html.to_string_lossy().into_owned())
511 );
512 Ok(())
513 }
514
515 #[tokio::test]
516 async fn reports_no_url_and_gist_failure() -> Result<(), Box<dyn std::error::Error>> {
517 let root = tempdir()?;
518 let html = root.path().join("session.html");
519 std::fs::write(&html, "html")?;
520 let no_url = FakeRunner::new([ok(""), ok("\n")]);
521 let error = share_html_file_with(&html, &no_url, &CancellationToken::new())
522 .await
523 .err()
524 .ok_or("expected parse error")?;
525 assert_eq!(error.to_string(), "Failed to parse gist ID from gh output");
526
527 let failed = FakeRunner::new([
528 ok(""),
529 FakeResponse::Output(CommandOutput {
530 status: Some(1),
531 stdout: String::new(),
532 stderr: "denied\n".to_owned(),
533 }),
534 ]);
535 let error = share_html_file_with(&html, &failed, &CancellationToken::new())
536 .await
537 .err()
538 .ok_or("expected gist error")?;
539 assert_eq!(error.to_string(), "Failed to create gist: denied");
540 Ok(())
541 }
542
543 #[tokio::test]
544 async fn temporary_html_is_present_for_upload_and_cleaned_afterward()
545 -> Result<(), Box<dyn std::error::Error>> {
546 let root = tempdir()?;
547 let session = persisted_session(root.path())?;
548 let runner = FakeRunner::new([ok(""), ok("https://gist.github.com/user/cleanup-id\n")]);
549 let result = share_session_with(&session, None, &runner, &CancellationToken::new()).await?;
550 assert_eq!(result.viewer_url, "https://pi.dev/session/#cleanup-id");
551 let temporary = runner.gist_file().ok_or("gist path not captured")?;
552 assert!(!temporary.exists());
553 assert!(!temporary.parent().is_some_and(Path::exists));
554 Ok(())
555 }
556
557 #[tokio::test]
558 async fn cancellation_propagates_and_cleans_temporary_html()
559 -> Result<(), Box<dyn std::error::Error>> {
560 let root = tempdir()?;
561 let session = persisted_session(root.path())?;
562 let runner = FakeRunner::new([ok(""), FakeResponse::WaitForCancellation]);
563 let cancellation = CancellationToken::new();
564 let cancel = cancellation.clone();
565 let future = share_session_with(&session, None, &runner, &cancellation);
566 tokio::pin!(future);
567 let result = tokio::select! {
568 biased;
569 () = async { tokio::task::yield_now().await; cancel.cancel(); } => (&mut future).await,
570 result = &mut future => result,
571 };
572 assert!(matches!(result, Err(ShareError::Cancelled)));
573 let temporary = runner.gist_file().ok_or("gist path not captured")?;
574 assert!(!temporary.exists());
575 Ok(())
576 }
577}