@@ -150,6 +150,137 @@ impl Builtin for Realpath {
150150 }
151151}
152152
153+ /// The readlink builtin - print resolved symbolic links or canonical file names.
154+ ///
155+ /// Usage: readlink [-f|-m|-e] FILE...
156+ ///
157+ /// Options:
158+ /// -f canonicalize: follow symlinks, resolve `.`/`..`; all but last component must exist
159+ /// -m canonicalize-missing: like -f but no component needs to exist
160+ /// -e canonicalize-existing: like -f but all components must exist
161+ /// (no flag) print symlink target without canonicalization
162+ pub struct Readlink ;
163+
164+ #[ async_trait]
165+ impl Builtin for Readlink {
166+ #[ allow( clippy:: collapsible_if) ]
167+ async fn execute ( & self , ctx : Context < ' _ > ) -> Result < ExecResult > {
168+ if ctx. args . is_empty ( ) {
169+ return Ok ( ExecResult :: err (
170+ "readlink: missing operand\n " . to_string ( ) ,
171+ 1 ,
172+ ) ) ;
173+ }
174+
175+ let mut mode = ReadlinkMode :: Raw ;
176+ let mut files: Vec < & str > = Vec :: new ( ) ;
177+
178+ for arg in ctx. args {
179+ match arg. as_str ( ) {
180+ "-f" => mode = ReadlinkMode :: Canonicalize ,
181+ "-m" => mode = ReadlinkMode :: CanonicalizeMissing ,
182+ "-e" => mode = ReadlinkMode :: CanonicalizeExisting ,
183+ "-n" | "-v" | "-q" | "-s" | "--no-newline" => { /* silently accept */ }
184+ s if s. starts_with ( '-' ) && s. len ( ) > 1 && !s. starts_with ( "--" ) => {
185+ // Could be combined flags like -fn
186+ for ch in s[ 1 ..] . chars ( ) {
187+ match ch {
188+ 'f' => mode = ReadlinkMode :: Canonicalize ,
189+ 'm' => mode = ReadlinkMode :: CanonicalizeMissing ,
190+ 'e' => mode = ReadlinkMode :: CanonicalizeExisting ,
191+ 'n' | 'v' | 'q' | 's' => { }
192+ _ => {
193+ return Ok ( ExecResult :: err (
194+ format ! ( "readlink: invalid option -- '{}'\n " , ch) ,
195+ 1 ,
196+ ) ) ;
197+ }
198+ }
199+ }
200+ }
201+ _ => files. push ( arg) ,
202+ }
203+ }
204+
205+ if files. is_empty ( ) {
206+ return Ok ( ExecResult :: err (
207+ "readlink: missing operand\n " . to_string ( ) ,
208+ 1 ,
209+ ) ) ;
210+ }
211+
212+ let mut output = String :: new ( ) ;
213+ let mut exit_code = 0 ;
214+
215+ for file in & files {
216+ let resolved = super :: resolve_path ( ctx. cwd , file) ;
217+
218+ match mode {
219+ ReadlinkMode :: Raw => {
220+ // No flag: read symlink target
221+ match ctx. fs . read_link ( & resolved) . await {
222+ Ok ( target) => {
223+ output. push_str ( & target. to_string_lossy ( ) ) ;
224+ output. push ( '\n' ) ;
225+ }
226+ Err ( _) => {
227+ exit_code = 1 ;
228+ }
229+ }
230+ }
231+ ReadlinkMode :: Canonicalize | ReadlinkMode :: CanonicalizeMissing => {
232+ // -f and -m: canonicalize path (resolve . and ..)
233+ // -m doesn't require existence, -f requires all but last
234+ let parent_missing = if mode == ReadlinkMode :: Canonicalize {
235+ resolved
236+ . parent ( )
237+ . filter ( |p| !p. as_os_str ( ) . is_empty ( ) )
238+ . map ( |p| ctx. fs . exists ( p) )
239+ } else {
240+ None
241+ } ;
242+ if let Some ( fut) = parent_missing {
243+ if !fut. await . unwrap_or ( false ) {
244+ exit_code = 1 ;
245+ continue ;
246+ }
247+ }
248+ output. push_str ( & resolved. to_string_lossy ( ) ) ;
249+ output. push ( '\n' ) ;
250+ }
251+ ReadlinkMode :: CanonicalizeExisting => {
252+ // -e: all components must exist
253+ if ctx. fs . exists ( & resolved) . await . unwrap_or ( false ) {
254+ output. push_str ( & resolved. to_string_lossy ( ) ) ;
255+ output. push ( '\n' ) ;
256+ } else {
257+ exit_code = 1 ;
258+ }
259+ }
260+ }
261+ }
262+
263+ if exit_code != 0 && output. is_empty ( ) {
264+ Ok ( ExecResult :: err ( String :: new ( ) , exit_code) )
265+ } else if exit_code != 0 {
266+ // Some files succeeded, some failed
267+ let mut result = ExecResult :: with_code ( output, exit_code) ;
268+ result. exit_code = exit_code;
269+ Ok ( result)
270+ } else {
271+ Ok ( ExecResult :: ok ( output) )
272+ }
273+ }
274+ }
275+
276+ #[ derive( PartialEq ) ]
277+ enum ReadlinkMode {
278+ Raw ,
279+ Canonicalize ,
280+ CanonicalizeMissing ,
281+ CanonicalizeExisting ,
282+ }
283+
153284#[ cfg( test) ]
154285#[ allow( clippy:: unwrap_used) ]
155286mod tests {
@@ -282,4 +413,110 @@ mod tests {
282413 assert_eq ! ( result. exit_code, 1 ) ;
283414 assert ! ( result. stderr. contains( "missing operand" ) ) ;
284415 }
416+
417+ // readlink tests
418+
419+ use crate :: fs:: FileSystem ;
420+
421+ async fn run_readlink_with_fs ( args : & [ & str ] , fs : Arc < dyn FileSystem > ) -> ExecResult {
422+ let mut variables = HashMap :: new ( ) ;
423+ let env = HashMap :: new ( ) ;
424+ let mut cwd = PathBuf :: from ( "/" ) ;
425+
426+ let args: Vec < String > = args. iter ( ) . map ( |s| s. to_string ( ) ) . collect ( ) ;
427+ let ctx = Context {
428+ args : & args,
429+ env : & env,
430+ variables : & mut variables,
431+ cwd : & mut cwd,
432+ fs,
433+ stdin : None ,
434+ #[ cfg( feature = "http_client" ) ]
435+ http_client : None ,
436+ #[ cfg( feature = "git" ) ]
437+ git_client : None ,
438+ } ;
439+
440+ Readlink . execute ( ctx) . await . unwrap ( )
441+ }
442+
443+ #[ tokio:: test]
444+ async fn test_readlink_missing_operand ( ) {
445+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
446+ let result = run_readlink_with_fs ( & [ ] , fs) . await ;
447+ assert_eq ! ( result. exit_code, 1 ) ;
448+ assert ! ( result. stderr. contains( "missing operand" ) ) ;
449+ }
450+
451+ #[ tokio:: test]
452+ async fn test_readlink_raw_symlink ( ) {
453+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
454+ fs. symlink ( Path :: new ( "/target" ) , Path :: new ( "/link" ) )
455+ . await
456+ . unwrap ( ) ;
457+ let result = run_readlink_with_fs ( & [ "/link" ] , fs) . await ;
458+ assert_eq ! ( result. exit_code, 0 ) ;
459+ assert_eq ! ( result. stdout, "/target\n " ) ;
460+ }
461+
462+ #[ tokio:: test]
463+ async fn test_readlink_raw_not_symlink ( ) {
464+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
465+ fs. write_file ( Path :: new ( "/file" ) , b"data" ) . await . unwrap ( ) ; // write a regular file
466+ let result = run_readlink_with_fs ( & [ "/file" ] , fs) . await ;
467+ // Not a symlink → failure, no output
468+ assert_eq ! ( result. exit_code, 1 ) ;
469+ assert ! ( result. stdout. is_empty( ) ) ;
470+ }
471+
472+ #[ tokio:: test]
473+ async fn test_readlink_raw_nonexistent ( ) {
474+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
475+ let result = run_readlink_with_fs ( & [ "/nonexistent" ] , fs) . await ;
476+ assert_eq ! ( result. exit_code, 1 ) ;
477+ }
478+
479+ #[ tokio:: test]
480+ async fn test_readlink_f_canonicalize ( ) {
481+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
482+ fs. mkdir ( Path :: new ( "/home" ) , true ) . await . unwrap ( ) ;
483+ fs. mkdir ( Path :: new ( "/home/user" ) , true ) . await . unwrap ( ) ;
484+ let result = run_readlink_with_fs ( & [ "-f" , "/home/user/../user/./file" ] , fs) . await ;
485+ assert_eq ! ( result. exit_code, 0 ) ;
486+ assert_eq ! ( result. stdout, "/home/user/file\n " ) ;
487+ }
488+
489+ #[ tokio:: test]
490+ async fn test_readlink_m_canonicalize_missing ( ) {
491+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
492+ // -m doesn't require existence
493+ let result = run_readlink_with_fs ( & [ "-m" , "/a/b/../c" ] , fs) . await ;
494+ assert_eq ! ( result. exit_code, 0 ) ;
495+ assert_eq ! ( result. stdout, "/a/c\n " ) ;
496+ }
497+
498+ #[ tokio:: test]
499+ async fn test_readlink_e_existing ( ) {
500+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
501+ fs. mkdir ( Path :: new ( "/existing" ) , false ) . await . unwrap ( ) ;
502+ let result = run_readlink_with_fs ( & [ "-e" , "/existing" ] , fs) . await ;
503+ assert_eq ! ( result. exit_code, 0 ) ;
504+ assert_eq ! ( result. stdout, "/existing\n " ) ;
505+ }
506+
507+ #[ tokio:: test]
508+ async fn test_readlink_e_nonexistent ( ) {
509+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
510+ let result = run_readlink_with_fs ( & [ "-e" , "/nonexistent" ] , fs) . await ;
511+ assert_eq ! ( result. exit_code, 1 ) ;
512+ assert ! ( result. stdout. is_empty( ) ) ;
513+ }
514+
515+ #[ tokio:: test]
516+ async fn test_readlink_invalid_option ( ) {
517+ let fs = Arc :: new ( InMemoryFs :: new ( ) ) as Arc < dyn FileSystem > ;
518+ let result = run_readlink_with_fs ( & [ "-z" , "/file" ] , fs) . await ;
519+ assert_eq ! ( result. exit_code, 1 ) ;
520+ assert ! ( result. stderr. contains( "invalid option" ) ) ;
521+ }
285522}
0 commit comments