Skip to content

Commit 941d32a

Browse files
CopilotibigbugCodexClaude
authored
feat: add URL playback support to play command (#280)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ibigbug <543405+ibigbug@users.noreply.github.com> Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com> Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com>
1 parent 05a3e6a commit 941d32a

5 files changed

Lines changed: 225 additions & 89 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ rustc_version_runtime = "0.3.0"
3434
os_info = "3"
3535
base64 = "0.22"
3636

37-
#[cfg(windows)]
37+
[target.'cfg(windows)'.dependencies]
3838
windows = { version = "0.62.2", features = [
3939
"Win32_Foundation",
4040
"Win32_Security",

src/commands/play.rs

Lines changed: 198 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::sync::{Condvar, Mutex};
1111
use std::time::Duration;
1212

1313
enum SessionLineSource {
14-
File(io::Lines<io::BufReader<File>>),
14+
Lines(io::Lines<Box<dyn BufRead>>),
1515
Vec(std::vec::IntoIter<SessionLine>),
1616
}
1717

@@ -29,7 +29,7 @@ impl Iterator for StdoutIter {
2929
fn next(&mut self) -> Option<Self::Item> {
3030
match &mut self.0.line_iter {
3131
SessionLineSource::Vec(iter) => iter.next(),
32-
SessionLineSource::File(iter) => iter.next().map(|line| {
32+
SessionLineSource::Lines(iter) => iter.next().map(|line| {
3333
let content = match line {
3434
Ok(l) => l,
3535
Err(e) => {
@@ -100,102 +100,167 @@ impl Iterator for StdoutRelativeTimeIter {
100100
}
101101
}
102102

103-
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
104-
where
105-
P: AsRef<Path>,
106-
{
107-
let file = File::open(filename)?;
108-
Ok(io::BufReader::new(file).lines())
103+
fn is_url(input: &str) -> bool {
104+
input.starts_with("http://") || input.starts_with("https://")
105+
}
106+
107+
/// Normalize an asciinema.org recording URL to its raw `.cast` download URL.
108+
/// For example, `https://asciinema.org/a/abc123` becomes
109+
/// `https://asciinema.org/a/abc123.cast`.
110+
/// Query strings and fragments are preserved (e.g. `?t=10` stays after `.cast`).
111+
fn normalize_url(url: &str) -> String {
112+
// Split off fragment, preserving the leading '#'
113+
let (before_fragment, fragment) = match url.find('#') {
114+
Some(idx) => (&url[..idx], &url[idx..]),
115+
None => (url, ""),
116+
};
117+
118+
// Split off query, preserving the leading '?'
119+
let (mut main, query) = match before_fragment.find('?') {
120+
Some(idx) => (&before_fragment[..idx], &before_fragment[idx..]),
121+
None => (before_fragment, ""),
122+
};
123+
124+
// Only normalize asciinema recording URLs
125+
if main.contains("asciinema.org/a/") {
126+
// Remove a trailing slash from the path, if present
127+
if main.ends_with('/') {
128+
main = &main[..main.len() - 1];
129+
}
130+
131+
let mut normalized = main.to_string();
132+
if !normalized.ends_with(".cast") {
133+
normalized.push_str(".cast");
134+
}
135+
136+
// Reattach query and fragment in their original order
137+
normalized.push_str(query);
138+
normalized.push_str(fragment);
139+
normalized
140+
} else {
141+
// Non-asciinema URLs are returned unchanged
142+
url.to_string()
143+
}
144+
}
145+
146+
/// Parse a session from a buffered reader, detecting v2 or v1 format automatically.
147+
/// The `source_name` is used only in error messages.
148+
fn parse_reader(reader: Box<dyn BufRead>, source_name: &str) -> Session {
149+
let mut line_iter: io::Lines<Box<dyn BufRead>> = reader.lines();
150+
151+
let first_line = match line_iter.next() {
152+
Some(Ok(line)) => line,
153+
Some(Err(e)) => {
154+
eprintln!("error reading '{}': {}", source_name, e);
155+
exit(1);
156+
}
157+
None => {
158+
eprintln!("'{}': file is empty", source_name);
159+
exit(1);
160+
}
161+
};
162+
163+
if let Ok(header) = serde_json::from_str::<RecordHeader>(&first_line) {
164+
// v2 format: header on first line, events stream on subsequent lines
165+
Session {
166+
header,
167+
line_iter: SessionLineSource::Lines(line_iter),
168+
}
169+
} else {
170+
// Try v1 format: entire content is a single JSON object.
171+
// Collect remaining lines from the already-opened iterator.
172+
let mut file_content = first_line;
173+
for line in line_iter {
174+
file_content.push('\n');
175+
match line {
176+
Ok(l) => file_content.push_str(&l),
177+
Err(e) => {
178+
eprintln!("error reading '{}': {}", source_name, e);
179+
exit(1);
180+
}
181+
}
182+
}
183+
match serde_json::from_str::<V1Recording>(&file_content) {
184+
Ok(recording) if recording.version == 1 => {
185+
let header = RecordHeader {
186+
version: recording.version,
187+
width: recording.width,
188+
height: recording.height,
189+
timestamp: 0,
190+
environment: HashMap::new(),
191+
};
192+
193+
let mut absolute_time: f64 = 0.0;
194+
let events: Vec<SessionLine> = recording
195+
.stdout
196+
.into_iter()
197+
.map(|(delay, text)| {
198+
absolute_time += delay;
199+
SessionLine {
200+
timestamp: absolute_time,
201+
stdout: true,
202+
content: text,
203+
}
204+
})
205+
.collect();
206+
207+
Session {
208+
header,
209+
line_iter: SessionLineSource::Vec(events.into_iter()),
210+
}
211+
}
212+
Ok(recording) => {
213+
eprintln!(
214+
"'{}': unsupported file format version {}",
215+
source_name, recording.version
216+
);
217+
exit(1);
218+
}
219+
Err(e) => {
220+
eprintln!(
221+
"'{}': unsupported or corrupt session file: {}",
222+
source_name, e
223+
);
224+
exit(1);
225+
}
226+
}
227+
}
109228
}
110229

111230
impl Session {
112-
fn new(filename: &str) -> Self {
231+
fn new(source: &str) -> Self {
232+
if is_url(source) { Self::from_url(source) } else { Self::from_file(source) }
233+
}
234+
235+
fn from_file(filename: &str) -> Self {
113236
if !Path::new(filename).exists() {
114237
eprintln!("session with name {} does not exist", filename);
115238
exit(1);
116239
}
117240

118-
let mut line_iter = match read_lines(filename) {
119-
Ok(iter) => iter,
241+
let file = match File::open(filename) {
242+
Ok(f) => f,
120243
Err(e) => {
121244
eprintln!("error opening '{}': {}", filename, e);
122245
exit(1);
123246
}
124247
};
125-
let first_line = match line_iter.next() {
126-
Some(Ok(line)) => line,
127-
Some(Err(e)) => {
128-
eprintln!("error reading '{}': {}", filename, e);
129-
exit(1);
130-
}
131-
None => {
132-
eprintln!("'{}': file is empty", filename);
133-
exit(1);
134-
}
135-
};
248+
parse_reader(Box::new(io::BufReader::new(file)), filename)
249+
}
136250

137-
if let Ok(header) = serde_json::from_str::<RecordHeader>(&first_line) {
138-
// v2 format: header on first line, events on subsequent lines
139-
Session {
140-
header,
141-
line_iter: SessionLineSource::File(line_iter),
142-
}
143-
} else {
144-
// Try v1 format: entire file is a single JSON object.
145-
// Collect remaining lines from the already-opened iterator to avoid re-reading the file.
146-
let mut file_content = first_line;
147-
for line in line_iter {
148-
file_content.push('\n');
149-
match line {
150-
Ok(l) => file_content.push_str(&l),
151-
Err(e) => {
152-
eprintln!("error reading '{}': {}", filename, e);
153-
exit(1);
154-
}
155-
}
156-
}
157-
match serde_json::from_str::<V1Recording>(&file_content) {
158-
Ok(recording) if recording.version == 1 => {
159-
let header = RecordHeader {
160-
version: recording.version,
161-
width: recording.width,
162-
height: recording.height,
163-
timestamp: 0,
164-
environment: HashMap::new(),
165-
};
166-
167-
let mut absolute_time: f64 = 0.0;
168-
let events: Vec<SessionLine> = recording
169-
.stdout
170-
.into_iter()
171-
.map(|(delay, text)| {
172-
absolute_time += delay;
173-
SessionLine {
174-
timestamp: absolute_time,
175-
stdout: true,
176-
content: text,
177-
}
178-
})
179-
.collect();
180-
181-
Session {
182-
header,
183-
line_iter: SessionLineSource::Vec(events.into_iter()),
184-
}
185-
}
186-
Ok(recording) => {
187-
eprintln!(
188-
"'{}': unsupported file format version {}",
189-
filename, recording.version
190-
);
191-
exit(1);
192-
}
193-
Err(e) => {
194-
eprintln!("'{}': unsupported or corrupt session file: {}", filename, e);
195-
exit(1);
196-
}
197-
}
251+
fn from_url(url: &str) -> Self {
252+
let url = normalize_url(url);
253+
let response = reqwest::blocking::get(&url).unwrap_or_else(|e| {
254+
eprintln!("failed to fetch URL {}: {}", url, e);
255+
exit(1);
256+
});
257+
258+
if !response.status().is_success() {
259+
eprintln!("failed to fetch URL {}: HTTP {}", url, response.status());
260+
exit(1);
198261
}
262+
263+
parse_reader(Box::new(io::BufReader::new(response)), &url)
199264
}
200265

201266
fn stdout_iter(self) -> StdoutIter {
@@ -245,6 +310,8 @@ mod tests {
245310
use crate::Play;
246311
use std::path::PathBuf;
247312

313+
use super::{is_url, normalize_url};
314+
248315
fn test_data_path() -> String {
249316
let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
250317
d.push("testdata/play.txt");
@@ -298,4 +365,52 @@ mod tests {
298365
let play = Play::new(test_data_v1_path(), Some(0.5), 1.0);
299366
play.execute();
300367
}
368+
369+
#[test]
370+
fn test_is_url() {
371+
assert!(is_url("https://asciinema.org/a/abc123"));
372+
assert!(is_url("http://example.com/recording.cast"));
373+
assert!(!is_url("my_recording.cast"));
374+
assert!(!is_url("/path/to/recording.cast"));
375+
assert!(!is_url("C:\\recordings\\session.cast"));
376+
}
377+
378+
#[test]
379+
fn test_normalize_url_asciinema() {
380+
// Basic recording URL
381+
assert_eq!(
382+
normalize_url("https://asciinema.org/a/abc123"),
383+
"https://asciinema.org/a/abc123.cast"
384+
);
385+
// Already has .cast – should not double-append
386+
assert_eq!(
387+
normalize_url("https://asciinema.org/a/abc123.cast"),
388+
"https://asciinema.org/a/abc123.cast"
389+
);
390+
// Non-asciinema URL – should be returned unchanged
391+
assert_eq!(
392+
normalize_url("https://example.com/recording.cast"),
393+
"https://example.com/recording.cast"
394+
);
395+
// URL with query string – .cast inserted before '?'
396+
assert_eq!(
397+
normalize_url("https://asciinema.org/a/abc123?t=10"),
398+
"https://asciinema.org/a/abc123.cast?t=10"
399+
);
400+
// URL with trailing slash – slash stripped before appending .cast
401+
assert_eq!(
402+
normalize_url("https://asciinema.org/a/abc123/"),
403+
"https://asciinema.org/a/abc123.cast"
404+
);
405+
// URL with fragment – .cast inserted before '#'
406+
assert_eq!(
407+
normalize_url("https://asciinema.org/a/abc123#intro"),
408+
"https://asciinema.org/a/abc123.cast#intro"
409+
);
410+
// URL with both query string and fragment
411+
assert_eq!(
412+
normalize_url("https://asciinema.org/a/abc123?t=10#intro"),
413+
"https://asciinema.org/a/abc123.cast?t=10#intro"
414+
);
415+
}
301416
}

src/commands/record.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ use windows::Win32::{
2323

2424
use crate::commands::types::LineItem;
2525
use crate::commands::types::RecordHeader;
26-
use crate::terminal::{Terminal, WindowsTerminal};
26+
#[cfg(windows)]
27+
use crate::terminal::Terminal;
28+
#[cfg(windows)]
29+
use crate::terminal::WindowsTerminal;
2730

2831
pub struct Record {
2932
output_writer: Arc<Mutex<Box<dyn Write + Send + Sync>>>,
@@ -58,6 +61,7 @@ impl Record {
5861
env: env.unwrap_or_default(),
5962
command: command
6063
.unwrap_or_else(|| env::var("SHELL").unwrap_or("powershell.exe".to_owned())),
64+
#[cfg(windows)]
6165
terminal: WindowsTerminal::new(None),
6266
}
6367
}
@@ -87,8 +91,14 @@ impl Record {
8791

8892
let header = RecordHeader {
8993
version: 2,
94+
#[cfg(windows)]
9095
width: self.terminal.width,
96+
#[cfg(not(windows))]
97+
width: 80,
98+
#[cfg(windows)]
9199
height: self.terminal.height,
100+
#[cfg(not(windows))]
101+
height: 24,
92102
timestamp: record_start_time as u64,
93103
environment: self.env.clone(),
94104
};
@@ -232,8 +242,18 @@ impl Record {
232242
}
233243
});
234244

235-
self.terminal.attach_stdin(stdin_rx);
236-
self.terminal.attach_stdout(stdout_tx);
237-
self.terminal.run(&self.command).unwrap();
245+
#[cfg(windows)]
246+
{
247+
self.terminal.attach_stdin(stdin_rx);
248+
self.terminal.attach_stdout(stdout_tx);
249+
self.terminal.run(&self.command).unwrap();
250+
}
251+
#[cfg(not(windows))]
252+
{
253+
drop(stdin_rx);
254+
drop(stdout_tx);
255+
eprintln!("error: recording is only supported on Windows");
256+
std::process::exit(1);
257+
}
238258
}
239259
}

0 commit comments

Comments
 (0)