Skip to main content

pi/core/
share.rs

1//! Secret-gist sharing through an injected `gh` command runner.
2
3use 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/// Captured command completion.
22#[derive(Clone, Debug, Default, Eq, PartialEq)]
23pub struct CommandOutput {
24    /// Process exit code (`None` when unavailable).
25    pub status: Option<i32>,
26    /// Captured standard output.
27    pub stdout: String,
28    /// Captured standard error.
29    pub stderr: String,
30}
31
32impl CommandOutput {
33    fn success(&self) -> bool {
34        self.status == Some(0)
35    }
36}
37
38/// Command-launch failures classified independently from process exit status.
39#[derive(Debug, Error)]
40pub enum CommandRunError {
41    /// Executable was not found.
42    #[error("command not found: {0}")]
43    NotFound(String),
44    /// Command I/O failed.
45    #[error(transparent)]
46    Io(#[from] io::Error),
47    /// The cancellation token fired.
48    #[error("command cancelled")]
49    Cancelled,
50}
51
52/// Injectable process boundary used by gist sharing.
53pub trait CommandRunner: Send + Sync {
54    /// Run one command with captured stdout/stderr and linked cancellation.
55    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/// Production command runner backed by `tokio::process`.
64#[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/// Successful secret-gist share URLs.
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct ShareResult {
111    /// `{PI_SHARE_VIEWER_URL or default}#{gistId}`.
112    pub viewer_url: String,
113    /// Raw gist URL selected from the final output line.
114    pub gist_url: String,
115}
116
117impl ShareResult {
118    /// Exact two-line interactive status text.
119    #[must_use]
120    pub fn status_text(&self) -> String {
121        format!("Share URL: {}\nGist: {}", self.viewer_url, self.gist_url)
122    }
123}
124
125/// Share failures with compatibility error text.
126#[derive(Debug, Error)]
127pub enum ShareError {
128    /// `gh` is absent from `PATH`.
129    #[error("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")]
130    GhNotInstalled,
131    /// `gh auth status` failed.
132    #[error("GitHub CLI is not logged in. Run 'gh auth login' first.")]
133    GhNotLoggedIn,
134    /// Session HTML generation failed.
135    #[error("Failed to export session: {0}")]
136    Export(#[from] ExportError),
137    /// `gh gist create` failed.
138    #[error("Failed to create gist: {0}")]
139    GistCreateFailed(String),
140    /// Successful command emitted no parseable URL.
141    #[error("Failed to parse gist ID from gh output")]
142    GistIdParseFailed,
143    /// Operation was cancelled and the child was terminated by the runner.
144    #[error("Share cancelled")]
145    Cancelled,
146    /// Temporary-file or command I/O failed.
147    #[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
161/// Check `gh` availability and authentication through an injected runner.
162///
163/// # Errors
164///
165/// Distinguishes an absent binary from a nonzero auth status and cancellation.
166pub 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
179/// Check `gh` availability and authentication with the system runner.
180///
181/// # Errors
182///
183/// See [`check_gh_auth_with`].
184pub 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
196/// Upload an existing HTML file as a secret gist through an injected runner.
197///
198/// # Errors
199///
200/// Returns exact authentication, command, cancellation, and URL parsing errors.
201pub 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
236/// Upload an existing HTML file with the system runner.
237///
238/// # Errors
239///
240/// See [`share_html_file_with`].
241pub 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
271/// Export a current session to temporary HTML and create a secret gist.
272///
273/// Authentication is checked before export. The temporary HTML and its unique
274/// directory are removed on success, error, and cancellation.
275///
276/// # Errors
277///
278/// Returns export or share errors described by [`ShareError`].
279pub 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
325/// Export and share a current session with the system command runner.
326///
327/// # Errors
328///
329/// See [`share_session_with`].
330pub 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}