diff --git a/.gitignore b/.gitignore index 378c1d9..a0a499e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,12 @@ report.html # pre-commit executable pre-commit.pyz +ignored_file +test_mixed/ignored.txt +test_ignored/ + +# Test scenarios +test_scenarios/fully_ignored/ +test_scenarios/partially_ignored/ignored.txt +test_scenarios/nested_ignored/sub1/ +*.log \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d609a0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`pls` is a prettier and powerful `ls(1)` replacement written in Rust. It's designed to be a modern, fast, and feature-rich directory listing tool with extensive customization options. + +## Architecture + +The codebase is organized into several key modules: + +- **Core**: `src/main.rs` contains the application entry point with a global `PLS` instance +- **Models**: `src/models/` contains core data structures including `Pls` (main application state), `Node` (file system entries), and `Window` (terminal info) +- **Configuration**: `src/config/` handles YAML configuration files and command-line argument parsing +- **Arguments**: `src/args/` manages command-line input processing and grouping +- **Output**: `src/output/` handles different display formats (grid, table, etc.) +- **Formatting**: `src/fmt/` provides text rendering and markup processing +- **Graphics**: `src/gfx/` handles terminal graphics protocol support (Kitty, SVG) +- **Enums**: `src/enums/` contains various enumeration types for appearance, sorting, etc. +- **Traits**: `src/traits/` defines common behaviors across different components +- **Utils**: `src/utils/` provides utility functions for paths, URLs, and vectors + +## Development Commands + +### Rust (Core Application) +- **Build**: `cargo build` +- **Run**: `cargo run -- [args]` or `just run [args]` +- **Test**: `cargo test` or `just test` +- **Debug**: `RUST_LOG=debug cargo run -- [args]` or `just debug [args]` +- **Release**: `cargo build --release` or `just release` + +### Frontend/Documentation (JavaScript/TypeScript) +- **Lint**: `pnpm lint` (ESLint) +- **Format**: `pnpm format` (Prettier) +- **Check formatting**: `pnpm format:check` +- **All checks**: `pnpm checks` (lint + format check) +- **Fix linting**: `pnpm lint:fix` + +### Project Management +- **Install dependencies**: `just install` (installs for all sub-projects) +- **Pre-commit setup**: `just pre-commit` (installs Git hooks) +- **Lint all**: `just lint` (runs pre-commit on all files) + +## Key Configuration + +The application uses: +- **Configuration files**: `.pls.yml` files for customization (handled by `ConfMan`) +- **Command-line args**: Parsed via `clap` crate +- **Environment**: `RUST_LOG` for debug logging +- **Package manager**: `pnpm` for JavaScript dependencies + +## Multi-Language Setup + +This is a polyglot project with: +- **Rust**: Main application (`src/`, `Cargo.toml`) +- **JavaScript/TypeScript**: Documentation site (`docs/`, Astro-based) +- **Python**: Examples and utilities (`examples/`, PDM-managed) +- **Just**: Task runner (`justfile` for automation) + +## Terminal Graphics + +The application detects and supports Kitty's terminal graphics protocol for enhanced visual output. This is handled in `src/gfx/` with runtime detection. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8f82603..d830850 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "figment", "git2", "home", + "lazy_static", "libc", "log", "number_prefix", diff --git a/Cargo.toml b/Cargo.toml index 23167de..5e3ecd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ env_logger = { version = "0.11.5", default-features = false } figment = { version = "0.10.10", features = ["yaml", "test"] } git2 = { version = "0.19.0", default-features = false } home = "0.5.5" +lazy_static = "1.4.0" libc = "0.2.158" log = { version = "0.4.19", features = ["release_max_level_off"] } number_prefix = "0.4.0" diff --git a/src/args/dir_group.rs b/src/args/dir_group.rs index 62ce5cd..1ec46f5 100644 --- a/src/args/dir_group.rs +++ b/src/args/dir_group.rs @@ -1,7 +1,7 @@ use crate::args::input::Input; use crate::enums::DetailField; use crate::exc::Exc; -use crate::models::{Node, OwnerMan}; +use crate::models::{GitMan, Node, OwnerMan}; use crate::traits::Imp; use crate::PLS; use log::debug; @@ -44,6 +44,7 @@ impl DirGroup { pub fn entries( &self, owner_man: &mut OwnerMan, + git_man: &mut GitMan, ) -> Result>, Exc> { let mut nodes = self.nodes()?; if PLS.args.collapse { @@ -61,6 +62,7 @@ impl DirGroup { &self.input.conf.entry_const, &[], None, + git_man, ) }) .collect(); @@ -83,7 +85,7 @@ impl DirGroup { /// /// If any criteria is not met, the node is not to be rendered and `None` is /// returned. - fn node(&self, entry: DirEntry) -> Option { + fn node(&self, entry: DirEntry) -> Option> { let name = entry.file_name(); debug!("Checking visibility of name {name:?}."); let haystack = name.as_bytes(); @@ -128,7 +130,7 @@ impl DirGroup { /// /// Unlike [`FilesGroup`](crate::args::files_group::FilesGroup), this /// function filters out nodes based on visibility. - fn nodes(&self) -> Result, Exc> { + fn nodes(&self) -> Result>, Exc> { let entries = self.input.path.read_dir().map_err(Exc::Io)?; let entries = entries diff --git a/src/args/files_group.rs b/src/args/files_group.rs index 607b6a5..ce36539 100644 --- a/src/args/files_group.rs +++ b/src/args/files_group.rs @@ -1,7 +1,7 @@ use crate::args::input::Input; use crate::config::{Conf, ConfMan}; use crate::enums::DetailField; -use crate::models::{Node, OwnerMan}; +use crate::models::{GitMan, Node, OwnerMan}; use crate::utils::paths::common_ancestor; use log::debug; use std::collections::HashMap; @@ -54,7 +54,7 @@ impl FilesGroup { /// Since individual nodes are not nested, the function uses each node's /// [`Node::row`] instead of the flattened output of each node's /// [`Node::entries`]. - pub fn entries(&self, owner_man: &mut OwnerMan) -> Vec> { + pub fn entries(&self, owner_man: &mut OwnerMan, git_man: &mut GitMan) -> Vec> { self.nodes() .iter() .map(|(node, conf)| { @@ -64,6 +64,7 @@ impl FilesGroup { &self.parent_conf.app_const, &conf.entry_const, &[], + git_man, ) }) .collect() @@ -79,7 +80,7 @@ impl FilesGroup { /// does not filter out nodes based on their visibility. This is because the /// files in this group have been explicitly provided by the user and should /// be rendered regardless of their visibility. - fn nodes(&self) -> Vec<(Node, &Conf)> { + fn nodes(&self) -> Vec<(Node<'_>, &Conf)> { self.inputs .iter() .map(|input| { diff --git a/src/args/group.rs b/src/args/group.rs index d1b42e6..c705077 100644 --- a/src/args/group.rs +++ b/src/args/group.rs @@ -5,7 +5,7 @@ use crate::config::{Conf, ConfMan}; use crate::enums::{DetailField, Typ}; use crate::exc::Exc; use crate::fmt::render; -use crate::models::OwnerMan; +use crate::models::{GitMan, OwnerMan}; use crate::output::{Grid, Table}; use crate::PLS; use std::collections::HashMap; @@ -53,7 +53,7 @@ impl Group { groups } - pub fn render(&self, show_title: bool, owner_man: &mut OwnerMan) -> Result<(), Exc> { + pub fn render(&self, show_title: bool, owner_man: &mut OwnerMan, git_man: &mut GitMan) -> Result<(), Exc> { if show_title { if let Self::Dir(group) = self { println!( @@ -63,7 +63,7 @@ impl Group { } } - let entries = self.entries(owner_man)?; + let entries = self.entries(owner_man, git_man)?; if PLS.args.grid { let grid = Grid::new(entries); @@ -93,10 +93,11 @@ impl Group { pub fn entries( &self, owner_man: &mut OwnerMan, + git_man: &mut GitMan, ) -> Result>, Exc> { match self { - Self::Dir(group) => group.entries(owner_man), - Self::Files(group) => Ok(group.entries(owner_man)), + Self::Dir(group) => group.entries(owner_man, git_man), + Self::Files(group) => Ok(group.entries(owner_man, git_man)), } } } diff --git a/src/config/entry_const.rs b/src/config/entry_const.rs index 873aa86..b78e3c0 100644 --- a/src/config/entry_const.rs +++ b/src/config/entry_const.rs @@ -26,6 +26,8 @@ pub struct EntryConst { pub blocks_style: String, /// mapping of timestamp fields to the human-readable format pub timestamp_formats: HashMap, + /// style for git status + pub git_style: String, /// mapping of symlink state to more symlink state info (including style) pub symlink: HashMap, } @@ -114,6 +116,7 @@ impl Default for EntryConst { ) }) .collect(), + git_style: String::from("cyan"), symlink: [ (SymState::Ok, "󰁔", "magenta", ""), // nf-md-arrow_right (SymState::Broken, "󱞣", "red", "strikethrough"), // nf-md-arrow_down_right diff --git a/src/git_cache.rs b/src/git_cache.rs new file mode 100644 index 0000000..d40a1a1 --- /dev/null +++ b/src/git_cache.rs @@ -0,0 +1,306 @@ +use git2::Repository; +use log::warn; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// A cache for git status information to avoid repeated git calls +pub struct GitStatusCache { + /// Cache mapping repository root paths to their status maps + /// The inner HashMap maps relative file paths to their git status strings + cache: HashMap>, + /// Cache for unpushed commits per repository + unpushed_cache: HashMap>, +} + +impl GitStatusCache { + pub fn new() -> Self { + Self { + cache: HashMap::new(), + unpushed_cache: HashMap::new(), + } + } + + /// Get the git status for a specific file path, using cached results when possible + pub fn get_status(&mut self, file_path: &Path) -> Option { + // Convert to absolute path first + let absolute_path = match file_path.canonicalize() { + Ok(path) => path, + Err(_) => return Some(" ".to_string()), + }; + + // Try to find the git repository from the file's path + let repo = match Repository::discover(&absolute_path) { + Ok(repo) => repo, + Err(_) => return Some(" ".to_string()), // Not in a git repo + }; + + // Get the repository root + let repo_root = match repo.workdir() { + Some(root) => root.to_path_buf(), + None => return Some(" ".to_string()), + }; + + // Check if we have cached results for this repository + if !self.cache.contains_key(&repo_root) { + self.populate_cache(&repo_root); + } + + // Get the relative path from the repository root + let relative_path = match absolute_path.strip_prefix(&repo_root) { + Ok(path) => path.to_string_lossy().to_string(), + Err(_) => return Some(" ".to_string()), + }; + + // Look up the status in our cache + if let Some(repo_cache) = self.cache.get(&repo_root) { + if let Some(status) = repo_cache.get(&relative_path) { + Some(status.clone()) + } else { + // File is not in git status output, check if it's in unpushed commits (committed but not pushed) + if let Some(unpushed_files) = self.unpushed_cache.get(&repo_root) { + if unpushed_files.contains(&relative_path) { + Some("↑ ".to_string()) + } else { + Some(" ".to_string()) + } + } else { + Some(" ".to_string()) + } + } + } else { + Some(" ".to_string()) + } + } + + /// Populate the cache for a specific repository using `git status --short` + fn populate_cache(&mut self, repo_root: &Path) { + let mut status_map = HashMap::new(); + + // Run git status --short to get all statuses at once + let output = match Command::new("git") + .arg("status") + .arg("--short") + .arg("--porcelain") + .current_dir(repo_root) + .output() + { + Ok(output) => output, + Err(e) => { + warn!("Failed to run git status: {}", e); + self.cache.insert(repo_root.to_path_buf(), status_map); + return; + } + }; + + if !output.status.success() { + warn!("git status command failed"); + self.cache.insert(repo_root.to_path_buf(), status_map); + return; + } + + // Parse the git status output + let status_output = String::from_utf8_lossy(&output.stdout); + for line in status_output.lines() { + if line.len() >= 3 { + let status_chars = &line[0..2]; + let file_path = &line[3..]; + + // Format the status with colors similar to the original implementation + let formatted_status = self.format_git_status_from_chars(status_chars); + status_map.insert(file_path.to_string(), formatted_status); + } + } + + // Populate the unpushed commits cache first + self.populate_unpushed_cache(repo_root); + + // Handle directories that contain modified files + // For each file, mark its parent directories appropriately + let file_paths: Vec = status_map.keys().cloned().collect(); + for file_path in file_paths { + let path = Path::new(&file_path); + let mut current_dir = path.parent(); + + while let Some(dir) = current_dir { + let dir_str = dir.to_string_lossy().to_string(); + if !dir_str.is_empty() && !status_map.contains_key(&dir_str) { + status_map.insert(dir_str, " *".to_string()); + } + current_dir = dir.parent(); + } + } + + // Handle directories that contain unpushed files (committed but not pushed) + // For each unpushed file, mark its parent directories with yellow up arrow if they don't already have a status + if let Some(unpushed_files) = self.unpushed_cache.get(repo_root) { + for file_path in unpushed_files { + let path = Path::new(file_path); + let mut current_dir = path.parent(); + + while let Some(dir) = current_dir { + let dir_str = dir.to_string_lossy().to_string(); + if !dir_str.is_empty() && !status_map.contains_key(&dir_str) { + // Check if this directory has any uncommitted files by looking at status_map + let has_uncommitted = status_map.keys().any(|key| { + let key_path = Path::new(key); + key_path.starts_with(&dir_str) && status_map.get(key).map(|s| s.contains("*")).unwrap_or(false) + }); + + // If directory doesn't have uncommitted files, show yellow up arrow for unpushed + if !has_uncommitted { + status_map.insert(dir_str, "↑ ".to_string()); + } + } + current_dir = dir.parent(); + } + } + } + + self.cache.insert(repo_root.to_path_buf(), status_map); + + // Also populate the unpushed commits cache + self.populate_unpushed_cache(repo_root); + } + + /// Populate the cache for unpushed commits + fn populate_unpushed_cache(&mut self, repo_root: &Path) { + let mut unpushed_files = Vec::new(); + + // First, check if there's an upstream branch + let upstream_check = Command::new("git") + .arg("rev-parse") + .arg("--abbrev-ref") + .arg("@{upstream}") + .current_dir(repo_root) + .output(); + + if upstream_check.is_err() || !upstream_check.as_ref().unwrap().status.success() { + // No upstream branch, check if there are any commits ahead of origin/main or origin/master + let origins = ["origin/main", "origin/master"]; + for origin in &origins { + let check_origin = Command::new("git") + .arg("rev-parse") + .arg("--verify") + .arg(origin) + .current_dir(repo_root) + .output(); + + if check_origin.is_ok() && check_origin.unwrap().status.success() { + // Found this origin branch, check for unpushed commits + if let Ok(output) = Command::new("git") + .arg("log") + .arg(format!("{}..HEAD", origin)) + .arg("--name-only") + .arg("--pretty=format:") + .current_dir(repo_root) + .output() + { + if output.status.success() { + let log_output = String::from_utf8_lossy(&output.stdout); + for line in log_output.lines() { + let line = line.trim(); + if !line.is_empty() { + unpushed_files.push(line.to_string()); + } + } + } + } + break; + } + } + + // Remove duplicates and store + unpushed_files.sort(); + unpushed_files.dedup(); + self.unpushed_cache + .insert(repo_root.to_path_buf(), unpushed_files); + return; + } + + // Get the list of commits that are ahead of the upstream + let output = match Command::new("git") + .arg("log") + .arg("@{upstream}..HEAD") + .arg("--name-only") + .arg("--pretty=format:") + .current_dir(repo_root) + .output() + { + Ok(output) => output, + Err(e) => { + warn!("Failed to run git log for unpushed commits: {}", e); + self.unpushed_cache + .insert(repo_root.to_path_buf(), unpushed_files); + return; + } + }; + + if !output.status.success() { + // Could be that there are no unpushed commits, which is fine + self.unpushed_cache + .insert(repo_root.to_path_buf(), unpushed_files); + return; + } + + // Parse the output to get file names + let log_output = String::from_utf8_lossy(&output.stdout); + for line in log_output.lines() { + let line = line.trim(); + if !line.is_empty() { + unpushed_files.push(line.to_string()); + } + } + + // Remove duplicates + unpushed_files.sort(); + unpushed_files.dedup(); + + self.unpushed_cache + .insert(repo_root.to_path_buf(), unpushed_files); + } + + /// Format git status characters into colored output similar to the original implementation + fn format_git_status_from_chars(&self, status_chars: &str) -> String { + let chars: Vec = status_chars.chars().collect(); + if chars.len() != 2 { + return " ".to_string(); + } + + let staged_char = chars[0]; + let unstaged_char = chars[1]; + + // Handle special cases + if status_chars == "!!" { + return "!!".to_string(); + } + if status_chars == "UU" { + return "UU".to_string(); + } + if status_chars == "??" { + return "??".to_string(); + } + + // Format with colors: green for staged, red for unstaged + let staged_formatted = if staged_char == ' ' { + " ".to_string() + } else { + format!("{}", staged_char) + }; + + let unstaged_formatted = if unstaged_char == ' ' { + " ".to_string() + } else { + format!("{}", unstaged_char) + }; + + format!("{}{}", staged_formatted, unstaged_formatted) + } + + /// Clear the cache (useful for testing or when repository state might have changed) + #[allow(dead_code)] + pub fn clear(&mut self) { + self.cache.clear(); + self.unpushed_cache.clear(); + } +} diff --git a/src/main.rs b/src/main.rs index e43f7ce..9ff290d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod enums; mod exc; mod ext; mod fmt; +mod git_cache; mod gfx; mod models; mod output; diff --git a/src/models.rs b/src/models.rs index 87b099d..b2d82ea 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,3 +1,4 @@ +mod git; mod node; mod owner; mod perm; @@ -5,6 +6,7 @@ mod pls; mod spec; mod window; +pub use git::GitMan; pub use node::Node; pub use owner::OwnerMan; pub use perm::Perm; diff --git a/src/models/git.rs b/src/models/git.rs new file mode 100644 index 0000000..58b263a --- /dev/null +++ b/src/models/git.rs @@ -0,0 +1,29 @@ +use crate::git_cache::GitStatusCache; +use std::path::Path; + +/// Manages git status information with caching to avoid repeated git calls +pub struct GitMan { + /// The git status cache + cache: GitStatusCache, +} + +impl Default for GitMan { + fn default() -> Self { + Self { + cache: GitStatusCache::new(), + } + } +} + +impl GitMan { + /// Get the git status for a specific file path, using cached results when possible + pub fn get_status(&mut self, file_path: &Path) -> Option { + self.cache.get_status(file_path) + } + + /// Clear the git status cache (useful when repository state might have changed) + #[allow(dead_code)] + pub fn clear_cache(&mut self) { + self.cache.clear(); + } +} \ No newline at end of file diff --git a/src/models/node.rs b/src/models/node.rs index 535032a..5680d17 100644 --- a/src/models/node.rs +++ b/src/models/node.rs @@ -1,6 +1,6 @@ use crate::config::{AppConst, Conf, EntryConst}; use crate::enums::{Appearance, Collapse, DetailField, Icon, Typ}; -use crate::models::{OwnerMan, Spec}; +use crate::models::{GitMan, OwnerMan, Spec}; use crate::traits::{Detail, Imp, Name, Sym}; use crate::PLS; use std::collections::{HashMap, HashSet}; @@ -287,6 +287,7 @@ impl<'pls> Node<'pls> { &self, detail: DetailField, owner_man: &mut OwnerMan, + git_man: &mut GitMan, entry_const: &EntryConst, ) -> String { let val = match detail { @@ -306,6 +307,7 @@ impl<'pls> Node<'pls> { DetailField::Atime => self.time(detail, entry_const), DetailField::Size => self.size(entry_const), DetailField::Blocks => self.blocks(entry_const), + DetailField::Git => self.git(git_man, entry_const), // `Typ` enum DetailField::Typ => Some(self.typ.ch(entry_const)), _ => Some(String::default()), @@ -323,6 +325,7 @@ impl<'pls> Node<'pls> { app_const: &AppConst, entry_const: &EntryConst, tree_shape: &[&str], + git_man: &mut GitMan, ) -> HashMap { PLS.args .details @@ -334,7 +337,7 @@ impl<'pls> Node<'pls> { self.display_name(conf, app_const, entry_const, tree_shape), ) } else { - (detail, self.get_value(detail, owner_man, entry_const)) + (detail, self.get_value(detail, owner_man, git_man, entry_const)) } }) .collect() @@ -353,6 +356,7 @@ impl<'pls> Node<'pls> { entry_const: &EntryConst, parent_shapes: &[&str], // list of shapes inherited from the parent own_shape: Option<&str>, // shape to show just before the current node + git_man: &mut GitMan, ) -> Vec> { // list of parent shapes to pass to the children let mut child_parent_shapes = parent_shapes.to_vec(); @@ -373,7 +377,7 @@ impl<'pls> Node<'pls> { all_shapes.push(more_shape); } - once(self.row(owner_man, conf, app_const, entry_const, &all_shapes)) + once(self.row(owner_man, conf, app_const, entry_const, &all_shapes, git_man)) .chain(self.children.iter().enumerate().flat_map(|(idx, child)| { let child_own_shape = if idx == self.children.len() - 1 { &app_const.tree.bend_dash @@ -388,6 +392,7 @@ impl<'pls> Node<'pls> { entry_const, &child_parent_shapes, Some(child_own_shape), + git_man, ) })) .collect() diff --git a/src/models/pls.rs b/src/models/pls.rs index 465bfa4..88c2193 100644 --- a/src/models/pls.rs +++ b/src/models/pls.rs @@ -1,7 +1,7 @@ use crate::args::{Group, Input}; use crate::config::{Args, ConfMan}; use crate::fmt::render; -use crate::models::{OwnerMan, Window}; +use crate::models::{GitMan, OwnerMan, Window}; /// Represents the entire application state. /// @@ -61,7 +61,7 @@ impl Pls { groups .iter() - .map(|group| group.render(show_title, &mut OwnerMan::default())) + .map(|group| group.render(show_title, &mut OwnerMan::default(), &mut GitMan::default())) .filter_map(|res| res.err()) .for_each(|res| println!("{res}")); } diff --git a/src/traits/detail.rs b/src/traits/detail.rs index 5379d5e..8ed44a3 100644 --- a/src/traits/detail.rs +++ b/src/traits/detail.rs @@ -1,8 +1,9 @@ use crate::config::EntryConst; use crate::enums::{DetailField, Typ}; use crate::ext::Ctime; -use crate::models::{Node, OwnerMan, Perm}; +use crate::models::{GitMan, Node, OwnerMan, Perm}; use crate::PLS; + use log::warn; #[cfg(unix)] use std::os::unix::fs::MetadataExt; @@ -28,6 +29,8 @@ pub trait Detail { fn size(&self, entry_const: &EntryConst) -> Option; fn blocks(&self, entry_const: &EntryConst) -> Option; fn time(&self, field: DetailField, entry_const: &EntryConst) -> Option; + + fn git(&self, git_man: &mut GitMan, entry_const: &EntryConst) -> Option; } impl Detail for Node<'_> { @@ -203,4 +206,12 @@ impl Detail for Node<'_> { dt.format(&format).unwrap() }) } + + /// Get the git status of the file or directory. + /// This function returns a marked-up string. + fn git(&self, git_man: &mut GitMan, _entry_const: &EntryConst) -> Option { + git_man.get_status(&self.path) + } } + + diff --git a/src/traits/sym.rs b/src/traits/sym.rs index 94e7269..81d3e8d 100644 --- a/src/traits/sym.rs +++ b/src/traits/sym.rs @@ -4,7 +4,7 @@ use crate::models::Node; use std::fs; pub trait Sym { - fn target(&self) -> Option; + fn target(&self) -> Option>; } impl Sym for Node<'_> { @@ -12,7 +12,7 @@ impl Sym for Node<'_> { /// /// If the node is not a symlink, the target is `None`. If the node is a /// symlink, the target is a variant of [`SymTarget`], wrapped in `Some`. - fn target(&self) -> Option { + fn target(&self) -> Option> { if self.typ != Typ::Symlink { return None; }