@@ -11,7 +11,7 @@ use std::sync::{Condvar, Mutex};
1111use std:: time:: Duration ;
1212
1313enum 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
111230impl 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}
0 commit comments