xvc_file/list/
mod.rs

1//! Data structures and functions specific to `xvc file list`
2//!
3//! - [ListCLI] defines the command line options
4//! - [cmd_list]  is the entry point to run the command
5
6use crate::common::{load_targets_from_store, targets_from_disk, FileTextOrBinary};
7use crate::error::Error;
8use crate::Result;
9use anyhow::anyhow;
10use chrono;
11use clap::Parser;
12use clap_complete::ArgValueCompleter;
13use xvc_core::util::completer::{strum_variants_completer, xvc_path_completer};
14
15use std::collections::{HashMap, HashSet};
16use std::fmt::Display;
17use std::fmt::Formatter;
18use std::path::Path;
19use std::str::FromStr;
20use std::time::SystemTime;
21use strum_macros::{Display as EnumDisplay, EnumString, VariantNames};
22use xvc_core::types::xvcdigest::DIGEST_LENGTH;
23use xvc_core::{conf, FromConfigKey, UpdateFromXvcConfig};
24use xvc_core::{error, output, XvcOutputSender};
25use xvc_core::{
26    ContentDigest, HashAlgorithm, RecheckMethod, XvcConfigResult, XvcFileType, XvcMetadata,
27    XvcPath, XvcRoot,
28};
29use xvc_core::{HStore, XvcEntity, XvcStore};
30
31/// Format specifier for file list columns
32#[derive(Debug, Clone, EnumString, EnumDisplay, PartialEq, Eq)]
33pub enum ListColumn {
34    /// Column for the actual content digest (base64 encoded).
35    #[strum(serialize = "acd64")]
36    ActualContentDigest64,
37
38    /// Column for the actual content digest (base8 encoded).
39    #[strum(serialize = "acd8")]
40    ActualContentDigest8,
41
42    /// Column for the actual file type.
43    #[strum(serialize = "aft")]
44    ActualFileType,
45
46    /// Column for the actual size of the file.
47    #[strum(serialize = "asz")]
48    ActualSize,
49
50    /// Column for the actual timestamp of the file.
51    #[strum(serialize = "ats")]
52    ActualTimestamp,
53
54    /// Column for the name of the file.
55    #[strum(serialize = "name")]
56    Name,
57
58    /// Column for the cache status of the file.
59    #[strum(serialize = "cst")]
60    CacheStatus,
61
62    /// Column for the recorded recheck method.
63    #[strum(serialize = "rrm")]
64    RecordedRecheckMethod,
65
66    /// Column for the recorded content digest (base64 encoded).
67    #[strum(serialize = "rcd64")]
68    RecordedContentDigest64,
69
70    /// Column for the recorded content digest (base8 encoded).
71    #[strum(serialize = "rcd8")]
72    RecordedContentDigest8,
73
74    /// Column for the recorded size of the file.
75    #[strum(serialize = "rsz")]
76    RecordedSize,
77
78    /// Column for the recorded timestamp of the file.
79    #[strum(serialize = "rts")]
80    RecordedTimestamp,
81
82    /// Column for a literal string value.
83    #[strum(disabled)]
84    Literal(String),
85}
86
87/// Represents the format of a list, including the columns to be displayed.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ListFormat {
90    /// A vector of [ListColumn] enums representing the columns in the table.
91    pub columns: Vec<ListColumn>,
92}
93
94impl FromStr for ListFormat {
95    type Err = crate::Error;
96
97    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
98        let mut columns = Vec::new();
99        for begin_marker in s.split("{{") {
100            let items = begin_marker.split("}}");
101            for item in items {
102                if let Ok(col) = item.parse::<ListColumn>() {
103                    columns.push(col);
104                } else {
105                    columns.push(ListColumn::Literal(item.to_string()));
106                }
107            }
108        }
109        Ok(Self { columns })
110    }
111}
112
113conf!(ListFormat, "file.list.format");
114
115/// Specify how to sort file list
116#[derive(Debug, Copy, Clone, EnumString, EnumDisplay, PartialEq, Eq, VariantNames)]
117pub enum ListSortCriteria {
118    #[strum(serialize = "none")]
119    /// No sorting
120    None,
121    #[strum(serialize = "name-asc")]
122    /// Sort by name in ascending order
123    NameAsc,
124    #[strum(serialize = "name-desc")]
125    /// Sort by name in descending order
126    NameDesc,
127    #[strum(serialize = "size-asc")]
128    /// Sort by size in ascending order
129    SizeAsc,
130    #[strum(serialize = "size-desc")]
131    /// Sort by size in descending order
132    SizeDesc,
133    #[strum(serialize = "t-asc", serialize = "timestamp-asc", serialize = "ts-asc")]
134    /// Sort by timestamp in ascending order
135    TimestampAsc,
136    #[strum(
137        serialize = "t-desc",
138        serialize = "timestamp-desc",
139        serialize = "ts-desc"
140    )]
141    /// Sort by timestamp in descending order
142    TimestampDesc,
143}
144conf!(ListSortCriteria, "file.list.sort");
145
146/// A single item in the list output
147#[derive(Debug, Clone, PartialEq)]
148pub struct ListRow {
149    /// The actual (on-disk) content digest of the file
150    pub actual_content_digest_str: String,
151    /// The actual (on-disk) file size
152    pub actual_size: u64,
153    /// The actual (on-disk) file size as a string
154    pub actual_size_str: String,
155    /// The actual (on-disk) file modification timestamp
156    pub actual_timestamp: SystemTime,
157    /// The actual (on-disk) file modification timestamp as a string
158    pub actual_timestamp_str: String,
159    /// The actual (on-disk) file type
160    pub actual_file_type: String,
161
162    /// The basename of the file
163    pub name: String,
164    /// The cache status of the file
165    pub cache_status: String,
166
167    /// The recheck method used to link to the cached file
168    pub recorded_recheck_method: String,
169    /// The recorded content digest of the file
170    pub recorded_content_digest_str: String,
171    /// The recorded size of the file
172    pub recorded_size: u64,
173    /// The recorded size of the file as a string
174    pub recorded_size_str: String,
175    /// The recorded timestamp of the file
176    // FIXME: This can be used as a separate field to sort in the future
177    #[allow(dead_code)]
178    pub recorded_timestamp: SystemTime,
179    /// The recorded timestamp of the file as a string
180    pub recorded_timestamp_str: String,
181}
182
183impl ListRow {
184    fn new(path_prefix: &Path, path_match: PathMatch) -> Result<Self> {
185        let actual_file_type =
186            String::from(if let Some(actual_metadata) = path_match.actual_metadata {
187                match actual_metadata.file_type {
188                    XvcFileType::Missing => "X",
189                    XvcFileType::File => "F",
190                    XvcFileType::Directory => "D",
191                    XvcFileType::Symlink => "S",
192                    XvcFileType::Hardlink => "H",
193                    XvcFileType::Reflink => "R",
194                }
195            } else {
196                "X"
197            });
198
199        let recorded_recheck_method =
200            if let Some(recheck_method) = path_match.recorded_recheck_method {
201                format_recheck_method(recheck_method)
202            } else {
203                "X".to_string()
204            };
205
206        let actual_content_digest_str = match path_match.actual_digest {
207            Some(digest) => format!("{}", digest),
208            None => str::repeat(" ", DIGEST_LENGTH * 2),
209        };
210
211        let actual_size = path_match
212            .actual_metadata
213            .and_then(|md| md.size)
214            .unwrap_or(0);
215
216        let actual_size_str = format_size(path_match.actual_metadata.and_then(|md| md.size));
217
218        let actual_timestamp = path_match
219            .actual_metadata
220            .and_then(|md| md.modified)
221            .unwrap_or(SystemTime::UNIX_EPOCH);
222
223        let actual_timestamp_str =
224            format_timestamp(path_match.actual_metadata.and_then(|md| md.modified));
225
226        let recorded_size = path_match
227            .recorded_metadata
228            .and_then(|md| md.size)
229            .unwrap_or(0);
230
231        let recorded_size_str = format_size(path_match.recorded_metadata.and_then(|md| md.size));
232
233        let recorded_timestamp = path_match
234            .recorded_metadata
235            .and_then(|md| md.modified)
236            .unwrap_or(SystemTime::UNIX_EPOCH);
237
238        let recorded_timestamp_str =
239            format_timestamp(path_match.recorded_metadata.and_then(|md| md.modified));
240
241        let recorded_content_digest_str = match path_match.recorded_digest {
242            Some(digest) => format!("{}", digest),
243            None => str::repeat(" ", DIGEST_LENGTH * 2),
244        };
245
246        let name = if let Some(ap) = path_match.actual_path {
247            ap.strip_prefix(path_prefix.to_string_lossy().as_ref())
248                .map_err(|e| Error::RelativeStripPrefixError { e })?
249                .to_string()
250        } else if let Some(rp) = path_match.recorded_path {
251            rp.strip_prefix(path_prefix.to_string_lossy().as_ref())
252                .map_err(|e| Error::RelativeStripPrefixError { e })?
253                .to_string()
254        } else {
255            return Err(anyhow!("No actual or recorded path for {:?}", path_match).into());
256        };
257
258        // We don't consider subsecond differences to be significant.
259        let cache_status = if path_match.actual_metadata.is_some() {
260            if path_match.recorded_metadata.is_some() {
261                // We use seconds resolution for file system changes not to change results
262                let actual_secs = actual_timestamp
263                    .duration_since(SystemTime::UNIX_EPOCH)?
264                    .as_secs();
265                let recorded_secs = recorded_timestamp
266                    .duration_since(SystemTime::UNIX_EPOCH)?
267                    .as_secs();
268                match actual_secs.cmp(&recorded_secs) {
269                    std::cmp::Ordering::Less => "<".to_string(),
270                    std::cmp::Ordering::Greater => ">".to_string(),
271                    std::cmp::Ordering::Equal => "=".to_string(),
272                }
273            } else {
274                "X".to_string()
275            }
276        } else {
277            "?".to_string()
278        };
279
280        Ok(ListRow {
281            actual_content_digest_str,
282            actual_file_type,
283            actual_size,
284            actual_size_str,
285            actual_timestamp,
286            actual_timestamp_str,
287            name,
288            cache_status,
289            recorded_recheck_method,
290            recorded_content_digest_str,
291            recorded_size,
292            recorded_size_str,
293            recorded_timestamp,
294            recorded_timestamp_str,
295        })
296    }
297}
298
299fn format_recheck_method(recheck_method: RecheckMethod) -> String {
300    match recheck_method {
301        RecheckMethod::Copy => "C".to_string(),
302        RecheckMethod::Symlink => "S".to_string(),
303        RecheckMethod::Hardlink => "H".to_string(),
304        RecheckMethod::Reflink => "R".to_string(),
305    }
306}
307
308/// Formats the timestamp with "%Y-%m-%d %H:%M:%S" if there is Some,
309/// or returns a corresponding string of spaces.
310pub fn format_timestamp(timestamp: Option<SystemTime>) -> String {
311    match timestamp {
312        Some(timestamp) => {
313            let timestamp = chrono::DateTime::<chrono::Utc>::from(timestamp);
314            format!("{}", timestamp.format("%Y-%m-%d %H:%M:%S"))
315        }
316        None => "                   ".to_string(),
317    }
318}
319
320/// Format size in human readable form, and shows the small changes.
321///
322/// For files larger than 1MB, it shows the last 3 digits, so that small changes are visible.
323///
324/// MB, GB, TB are used for sizes larger than 1MB, 1GB, 1TB respectively
325/// Calculations for these are done with 1024 as base, not 1000.
326///
327/// Returns a string of spaces with the same size of column if size is None.
328pub fn format_size(size: Option<u64>) -> String {
329    match size {
330        Some(size) => {
331            if size < 1024 * 1024 {
332                format!("{:11}", size)
333            } else if size < 1024 * 1024 * 1024 {
334                format!("{:>4}MB.{}", size / 1024 / 1024, size % 1000)
335            } else if size < 1024 * 1024 * 1024 * 1024 {
336                format!("{:>4}GB.{}", size / 1024 / 1024 / 1024, size % 1000)
337            } else {
338                format!("{:>4}TB.{}", size / 1024 / 1024 / 1024 / 1024, size % 1000)
339            }
340        }
341        None => "           ".to_owned(),
342    }
343}
344
345#[derive(Debug, Clone)]
346struct PathMatch {
347    xvc_entity: Option<XvcEntity>,
348    actual_path: Option<XvcPath>,
349    actual_metadata: Option<XvcMetadata>,
350    actual_digest: Option<ContentDigest>,
351    recorded_path: Option<XvcPath>,
352    recorded_metadata: Option<XvcMetadata>,
353    recorded_digest: Option<ContentDigest>,
354    recorded_recheck_method: Option<RecheckMethod>,
355}
356
357/// All rows of the file list and its format and sorting criteria
358#[derive(Debug, Clone, PartialEq)]
359pub struct ListRows {
360    /// How to format the file row. See [ListColumn] for the available columns.
361    pub format: ListFormat,
362    /// How to sort the list. See [ListSortCriteria] for the available criteria.
363    pub sort_criteria: ListSortCriteria,
364    /// All elements of the file list
365    pub rows: Vec<ListRow>,
366}
367
368impl ListRows {
369    /// Create a new table with the specified params and sort it
370    pub fn new(format: ListFormat, sort_criteria: ListSortCriteria, rows: Vec<ListRow>) -> Self {
371        let mut s = Self {
372            format,
373            sort_criteria,
374            rows,
375        };
376        sort_list_rows(&mut s);
377        s
378    }
379
380    /// Create an empty table without any rows, format or sorting criteria
381    pub fn empty() -> Self {
382        Self {
383            format: ListFormat { columns: vec![] },
384            sort_criteria: ListSortCriteria::None,
385            rows: vec![],
386        }
387    }
388
389    /// Number if file lines in the table
390    pub fn total_lines(&self) -> usize {
391        self.rows.len()
392    }
393
394    /// Total size of the files in the table
395    pub fn total_actual_size(&self) -> u64 {
396        self.rows.iter().fold(0u64, |tot, r| tot + r.actual_size)
397    }
398
399    /// Total size of the recorded files in the table
400    pub fn total_cached_size(&self) -> u64 {
401        let mut cached_sizes = HashMap::<String, u64>::new();
402        self.rows.iter().for_each(|r| {
403            if !r.recorded_content_digest_str.trim().is_empty() {
404                cached_sizes.insert(r.recorded_content_digest_str.to_string(), r.recorded_size);
405            }
406        });
407
408        cached_sizes.values().sum()
409    }
410}
411
412/// Print a single row from the given element and the format
413pub fn build_row(row: &ListRow, format: &ListFormat) -> String {
414    let mut output = String::new();
415    for column in &format.columns {
416        match column {
417            ListColumn::RecordedRecheckMethod => output.push_str(&row.recorded_recheck_method),
418            ListColumn::ActualFileType => output.push_str(&row.actual_file_type),
419            ListColumn::ActualSize => output.push_str(&row.actual_size_str),
420            ListColumn::ActualContentDigest64 => output.push_str(&row.actual_content_digest_str),
421            ListColumn::ActualContentDigest8 => {
422                output.push_str(if row.actual_content_digest_str.len() >= 8 {
423                    &row.actual_content_digest_str[..8]
424                } else {
425                    &row.actual_content_digest_str
426                })
427            }
428            ListColumn::ActualTimestamp => output.push_str(&row.actual_timestamp_str),
429            ListColumn::Name => output.push_str(&row.name),
430            ListColumn::RecordedSize => output.push_str(&row.recorded_size_str),
431            ListColumn::RecordedContentDigest64 => {
432                output.push_str(&row.recorded_content_digest_str)
433            }
434            ListColumn::RecordedContentDigest8 => {
435                output.push_str(if row.recorded_content_digest_str.len() >= 8 {
436                    &row.recorded_content_digest_str[..8]
437                } else {
438                    &row.recorded_content_digest_str
439                })
440            }
441            ListColumn::RecordedTimestamp => output.push_str(&row.recorded_timestamp_str),
442            ListColumn::CacheStatus => output.push_str(&row.cache_status),
443            ListColumn::Literal(literal) => output.push_str(literal),
444        }
445    }
446    output
447}
448
449/// Fn type to decouple the build_row function from the build_table function
450type BuildRowFn = Box<dyn Fn(&ListRow, &ListFormat) -> String>;
451
452/// Build a table from the list of rows
453pub fn build_table(list_rows: &ListRows, build_row: BuildRowFn) -> String {
454    let mut output = String::new();
455
456    let format = &list_rows.format;
457    for row in list_rows.rows.iter() {
458        let row_str = build_row(row, format);
459        output.push_str(&row_str);
460        output.push('\n');
461    }
462
463    output
464}
465
466fn add_summary_line(list_rows: &ListRows) -> String {
467    let total_lines = list_rows.total_lines();
468    let total_actual_size = format_size(Some(list_rows.total_actual_size()));
469    let total_cached_size = format_size(Some(list_rows.total_cached_size()));
470
471    // TODO: Add a format string to this output similar to files
472    format!("Total #: {total_lines} Workspace Size: {total_actual_size} Cached Size: {total_cached_size}\n")
473}
474
475fn sort_list_rows(list_rows: &mut ListRows) {
476    let row_cmp = match list_rows.sort_criteria {
477        ListSortCriteria::NameAsc => |a: &ListRow, b: &ListRow| a.name.cmp(&b.name),
478        ListSortCriteria::NameDesc => |a: &ListRow, b: &ListRow| b.name.cmp(&a.name),
479        ListSortCriteria::SizeAsc => |a: &ListRow, b: &ListRow| a.actual_size.cmp(&b.actual_size),
480        ListSortCriteria::SizeDesc => |a: &ListRow, b: &ListRow| b.actual_size.cmp(&a.actual_size),
481        ListSortCriteria::TimestampAsc => {
482            |a: &ListRow, b: &ListRow| a.actual_timestamp.cmp(&b.actual_timestamp)
483        }
484        ListSortCriteria::TimestampDesc => {
485            |a: &ListRow, b: &ListRow| b.actual_timestamp.cmp(&a.actual_timestamp)
486        }
487        ListSortCriteria::None => |_: &ListRow, _: &ListRow| std::cmp::Ordering::Equal,
488    };
489
490    list_rows.rows.sort_unstable_by(row_cmp);
491}
492
493impl Display for ListRows {
494    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
495        write!(f, "{}", build_table(self, Box::new(build_row)))?;
496        Ok(())
497    }
498}
499
500/// List files and their actual and cached metadata.
501///
502/// By default, it produces a list of files and directories in the current
503/// directory.
504/// The list can be formatted by using the --format option.
505/// The default format can be set in the config file.
506///
507///
508/// The command doesn't compute the actual content digest if it's not requested
509/// in the format string.
510///
511/// By default the list is not sorted.
512/// You can use the --sort option to sort the list by name, size or timestamp.
513///
514
515#[derive(Debug, Clone, PartialEq, Eq, Parser)]
516#[command(rename_all = "kebab-case")]
517pub struct ListCLI {
518    /// A string for each row of the output table
519    ///
520    /// The following are the keys for each row:
521    ///
522    /// - {{acd8}}:  actual content digest from the workspace file. First 8 digits.
523    /// - {{acd64}}:  actual content digest. All 64 digits.
524    /// - {{aft}}:  actual file type. Whether the entry is a file (F), directory (D),
525    ///   symlink (S), hardlink (H) or reflink (R).
526    /// - {{asz}}:  actual size. The size of the workspace file in bytes. It uses MB,
527    ///   GB and TB to represent sizes larger than 1MB.
528    /// - {{ats}}:  actual timestamp. The timestamp of the workspace file.
529    /// - {{name}}: The name of the file or directory.
530    /// - {{cst}}:  cache status. One of "=", ">", "<", or "X" to show
531    ///   whether the file timestamp is the same as the cached timestamp, newer,
532    ///   older, and not tracked.
533    /// - {{rcd8}}:  recorded content digest stored in the cache. First 8 digits.
534    /// - {{rcd64}}:  recorded content digest stored in the cache. All 64 digits.
535    /// - {{rrm}}:  recorded recheck method. Whether the entry is linked to the workspace
536    ///   as a copy (C), symlink (S), hardlink (H) or reflink (R).
537    /// - {{rsz}}:  recorded size. The size of the cached content in bytes. It uses
538    ///   MB, GB and TB to represent sizes larged than 1MB.
539    /// - {{rts}}:  recorded timestamp. The timestamp of the cached content.
540    ///
541    /// The default format can be set with file.list.format in the config file.
542    ///
543    /// TODO: Think how to add a completion to ListFormat
544    #[arg(long, short = 'f', verbatim_doc_comment)]
545    pub format: Option<ListFormat>,
546    /// Sort criteria.
547    ///
548    /// It can be one of none (default), name-asc, name-desc, size-asc, size-desc, ts-asc, ts-desc.
549    ///
550    /// The default option can be set with file.list.sort in the config file.
551    #[arg(long, short = 's', add = ArgValueCompleter::new(strum_variants_completer::<ListSortCriteria>))]
552    pub sort: Option<ListSortCriteria>,
553
554    /// Don't show total number and size of the listed files.
555    ///
556    /// The default option can be set with file.list.no_summary in the config file.
557    #[arg(long)]
558    pub no_summary: bool,
559
560    /// Don't hide directories
561    ///
562    /// Directories are not listed by default. This flag lists them.
563    #[arg(long, short = 'd', aliases=&["show-dirs"])]
564    pub show_directories: bool,
565
566    /// Don't hide dot files
567    ///
568    /// If not supplied, hides dot files like .gitignore and .xvcignore
569    #[arg(long, short = 'D')]
570    pub show_dot_files: bool,
571
572    /// List files tracked by Git.
573    ///
574    /// By default, Xvc doesn't list files tracked by Git. Supply this option to list them.
575    #[arg(long)]
576    pub include_git_files: bool,
577
578    /// Files/directories to list.
579    ///
580    /// If not supplied, lists all files under the current directory.
581    #[arg(add = ArgValueCompleter::new(xvc_path_completer))]
582    pub targets: Option<Vec<String>>,
583}
584
585impl UpdateFromXvcConfig for ListCLI {
586    fn update_from_conf(self, conf: &xvc_core::XvcConfig) -> XvcConfigResult<Box<Self>> {
587        let no_summary = self.no_summary || conf.get_bool("file.list.no_summary")?.option;
588        let show_dot_files =
589            self.show_dot_files || conf.get_bool("file.list.show_dot_files")?.option;
590
591        let format = self.format.unwrap_or_else(|| ListFormat::from_conf(conf));
592        let sort_criteria = self
593            .sort
594            .unwrap_or_else(|| ListSortCriteria::from_conf(conf));
595        let include_git_files =
596            self.include_git_files || conf.get_bool("file.list.include_git_files")?.option;
597
598        Ok(Box::new(Self {
599            no_summary,
600            show_dot_files,
601            include_git_files,
602            format: Some(format),
603            sort: Some(sort_criteria),
604            ..self
605        }))
606    }
607}
608
609/// ## Output Format
610///
611/// The default format for the output is as follows:
612///
613/// XY  <Timestamp>     <Size>     <Name>   <Digest>
614///
615/// X shows the recheck method from [RecheckMethod]
616/// - C: Copy
617/// - H: Hardlink
618/// - S: Symlink
619/// - R: Reflink
620/// - D: Directory
621/// - M: Missing (Not Tracked)
622///
623/// Y is the current cache status
624/// - =: Recorded and actual file have the same timestamp
625/// - >: Cached file is newer, xvc recheck to update the file
626/// - <: File is newer, xvc carry-in to update the cache
627///
628/// TODO: - I: File is ignored
629pub fn cmd_list(output_snd: &XvcOutputSender, xvc_root: &XvcRoot, cli_opts: ListCLI) -> Result<()> {
630    // FIXME: `opts` shouldn't be sent to the inner function, but we cannot make sure that it's
631    // updated from the config files in callers. A refactoring is good here.
632    let conf = xvc_root.config();
633    let opts = cli_opts.update_from_conf(conf)?;
634    let no_summary = opts.no_summary;
635    let list_rows = cmd_list_inner(output_snd, xvc_root, &opts)?;
636
637    // TODO: All output should be produced in a central location with implemented traits.
638    // [ListRows] could receive no_summary when it's built and implement Display
639    output!(
640        output_snd,
641        "{}",
642        build_table(&list_rows, Box::new(build_row))
643    );
644    if !no_summary {
645        output!(output_snd, "{}", add_summary_line(&list_rows));
646    }
647
648    Ok(())
649}
650
651/// The actual implementation moved here to get the listed elements separately to be used in
652/// desktop and server
653pub fn cmd_list_inner(
654    output_snd: &XvcOutputSender,
655    xvc_root: &XvcRoot,
656    opts: &ListCLI,
657) -> Result<ListRows> {
658    let conf = xvc_root.config();
659
660    let current_dir = conf.current_dir()?;
661    let filter_git_paths = !opts.include_git_files;
662
663    let all_from_disk = targets_from_disk(
664        output_snd,
665        xvc_root,
666        current_dir,
667        &opts.targets,
668        filter_git_paths,
669    )?;
670
671    let from_disk = filter_xvc_path_metadata_map(all_from_disk, opts);
672
673    let from_store = load_targets_from_store(output_snd, xvc_root, current_dir, &opts.targets)?;
674
675    let xvc_metadata_store = xvc_root.load_store::<XvcMetadata>()?;
676
677    let recheck_method_store = xvc_root.load_store::<RecheckMethod>()?;
678
679    let filter_keys = filter_xvc_path_xvc_metadata_stores(&from_store, &xvc_metadata_store, opts);
680
681    let from_store = from_store.subset(filter_keys.iter().copied())?;
682    let xvc_metadata_store = xvc_metadata_store.subset(filter_keys.iter().copied())?;
683    let recheck_method_store = recheck_method_store.subset(filter_keys.into_iter())?;
684
685    let matches = match_store_and_disk_paths(
686        from_disk,
687        from_store,
688        xvc_metadata_store,
689        recheck_method_store,
690    );
691
692    let matches = if opts.format.as_ref().unwrap().columns.iter().any(|c| {
693        *c == ListColumn::RecordedContentDigest64 || *c == ListColumn::RecordedContentDigest8
694    }) {
695        fill_recorded_content_digests(xvc_root, matches)?
696    } else {
697        matches
698    };
699
700    let matches =
701        if opts.format.as_ref().unwrap().columns.iter().any(|c| {
702            *c == ListColumn::ActualContentDigest64 || *c == ListColumn::ActualContentDigest8
703        }) {
704            let algorithm = HashAlgorithm::from_conf(conf);
705            fill_actual_content_digests(output_snd, xvc_root, algorithm, matches)?
706        } else {
707            matches
708        };
709
710    let path_prefix = current_dir.strip_prefix(xvc_root.absolute_path())?;
711
712    let rows = build_rows_from_matches(output_snd, matches, path_prefix);
713    let format = opts
714        .format
715        .clone()
716        .expect("Option must be filled at this point");
717    let sort_criteria = opts.sort.expect("Option must be filled at this point");
718
719    let list_rows = ListRows::new(format, sort_criteria, rows);
720    Ok(list_rows)
721}
722
723fn build_rows_from_matches(
724    output_snd: &XvcOutputSender,
725    matches: Vec<PathMatch>,
726    path_prefix: &Path,
727) -> Vec<ListRow> {
728    matches
729        .into_iter()
730        .filter_map(|pm| match ListRow::new(path_prefix, pm) {
731            Ok(lr) => Some(lr),
732            Err(e) => {
733                error!(output_snd, "{}", e);
734                None
735            }
736        })
737        .collect()
738}
739
740fn fill_actual_content_digests(
741    output_snd: &XvcOutputSender,
742    xvc_root: &XvcRoot,
743    algorithm: HashAlgorithm,
744    matches: Vec<PathMatch>,
745) -> Result<Vec<PathMatch>> {
746    let text_or_binary_store = xvc_root.load_store::<FileTextOrBinary>()?;
747    Ok(matches
748        .into_iter()
749        .filter_map(|pm| {
750            if pm
751                .actual_path
752                .as_deref()
753                .and(pm.actual_metadata.map(|md| md.is_file()))
754                == Some(true)
755            {
756                let actual_path = pm.actual_path.as_ref().unwrap();
757                let path = actual_path.to_absolute_path(xvc_root);
758                let text_or_binary = if let Some(xvc_entity) = pm.xvc_entity {
759                    text_or_binary_store
760                        .get(&xvc_entity)
761                        .copied()
762                        .unwrap_or_default()
763                } else {
764                    FileTextOrBinary::default()
765                };
766
767                match ContentDigest::new(&path, algorithm, text_or_binary.as_inner()) {
768                    Ok(digest) => Some(PathMatch {
769                        actual_digest: Some(digest),
770                        ..pm
771                    }),
772                    Err(e) => {
773                        error!(output_snd, "{}", e);
774                        None
775                    }
776                }
777            } else {
778                Some(pm)
779            }
780        })
781        .collect())
782}
783
784fn fill_recorded_content_digests(
785    xvc_root: &std::sync::Arc<xvc_core::types::xvcroot::XvcRootInner>,
786    matches: Vec<PathMatch>,
787) -> Result<Vec<PathMatch>> {
788    let content_digest_store = xvc_root.load_store::<ContentDigest>()?;
789    let matches: Vec<PathMatch> = matches
790        .into_iter()
791        .map(|pm| {
792            if let Some(xvc_entity) = pm.xvc_entity {
793                let digest = content_digest_store.get(&xvc_entity).cloned();
794                PathMatch {
795                    recorded_digest: digest,
796                    ..pm
797                }
798            } else {
799                pm
800            }
801        })
802        .collect();
803    Ok(matches)
804}
805
806/// There are four groups of paths:
807/// 1. Paths that are in the store and on disk and have identical metadata
808/// 2. Paths that are in the store and on disk but have different metadata
809/// 3. Paths that are in the store but not on disk
810/// 4. Paths that are on disk but not in the store
811fn match_store_and_disk_paths(
812    from_disk: HashMap<XvcPath, XvcMetadata>,
813    from_store: HStore<XvcPath>,
814    stored_xvc_metadata: HStore<XvcMetadata>,
815    stored_recheck_method: HStore<RecheckMethod>,
816) -> Vec<PathMatch> {
817    // Now match actual and recorded paths
818
819    let mut matches = Vec::<PathMatch>::new();
820
821    let mut found_entities = HashSet::<XvcEntity>::new();
822
823    for (disk_xvc_path, disk_xvc_md) in from_disk {
824        // Group 1 and Group 2
825        if let Some(xvc_entity) = from_store.entity_by_value(&disk_xvc_path) {
826            let recorded_metadata = stored_xvc_metadata.get(&xvc_entity).cloned();
827            let recorded_path = from_store.get(&xvc_entity).cloned();
828            let recorded_recheck_method = stored_recheck_method.get(&xvc_entity).cloned();
829            found_entities.insert(xvc_entity);
830            let pm = PathMatch {
831                xvc_entity: Some(xvc_entity),
832                actual_path: Some(disk_xvc_path),
833                actual_metadata: Some(disk_xvc_md),
834                // digests will be filled later if needed
835                actual_digest: None,
836
837                recorded_metadata,
838                recorded_path,
839                recorded_recheck_method,
840                recorded_digest: None,
841            };
842            matches.push(pm);
843        } else {
844            // Group 4
845            let pm = PathMatch {
846                xvc_entity: None,
847                actual_path: Some(disk_xvc_path),
848                actual_metadata: Some(disk_xvc_md),
849                // digests will be filled later if needed
850                actual_digest: None,
851
852                recorded_recheck_method: None,
853                recorded_metadata: None,
854                recorded_path: None,
855                recorded_digest: None,
856            };
857            matches.push(pm);
858        }
859    }
860
861    // Group 3
862    let not_found_entities = from_store
863        .keys()
864        .copied()
865        .filter(|xvc_entity| !found_entities.contains(xvc_entity))
866        .collect::<Vec<_>>();
867
868    for xvc_entity in &not_found_entities {
869        let recorded_metadata = stored_xvc_metadata.get(xvc_entity).cloned();
870        let recorded_path = from_store.get(xvc_entity).cloned();
871        let recorded_recheck_method = stored_recheck_method.get(xvc_entity).cloned();
872        let pm = PathMatch {
873            xvc_entity: Some(*xvc_entity),
874            actual_path: None,
875            actual_metadata: None,
876            // digests will be filled later if needed
877            actual_digest: None,
878            recorded_path,
879            recorded_metadata,
880            recorded_recheck_method,
881            recorded_digest: None,
882        };
883        matches.push(pm);
884    }
885    matches
886}
887
888fn filter_xvc_path_metadata_map(
889    all_from_disk: HashMap<XvcPath, XvcMetadata>,
890    opts: &ListCLI,
891) -> HashMap<XvcPath, XvcMetadata> {
892    let filter_fn = match (opts.show_dot_files, opts.show_directories) {
893        (true, true) => |_, _| true,
894
895        (true, false) => |_, md: &XvcMetadata| !md.is_dir(),
896        (false, true) => |path: &XvcPath, _| !(path.starts_with_str(".") || path.contains("./")),
897        (false, false) => |path: &XvcPath, md: &XvcMetadata| {
898            !(path.starts_with_str(".") || path.contains("./") || md.is_dir())
899        },
900    };
901
902    all_from_disk
903        .iter()
904        .filter_map(|(path, md)| {
905            if filter_fn(path, md) {
906                Some((path.clone(), *md))
907            } else {
908                None
909            }
910        })
911        .collect()
912}
913
914fn filter_xvc_path_xvc_metadata_stores(
915    xvc_path_store: &HStore<XvcPath>,
916    xvc_metadata_store: &XvcStore<XvcMetadata>,
917    opts: &ListCLI,
918) -> HashSet<XvcEntity> {
919    let filter_fn = match (opts.show_dot_files, opts.show_directories) {
920        (true, true) => |_, _| true,
921
922        (true, false) => |_, md: &XvcMetadata| !md.is_dir(),
923        (false, true) => |path: &XvcPath, _| !(path.starts_with_str(".") || path.contains("./")),
924        (false, false) => |path: &XvcPath, md: &XvcMetadata| {
925            !(path.starts_with_str(".") || path.contains("./") || md.is_dir())
926        },
927    };
928
929    xvc_path_store
930        .iter()
931        .filter_map(|(xvc_entity, xvc_path)| {
932            if let Some(xvc_metadata) = xvc_metadata_store.get(xvc_entity) {
933                if filter_fn(xvc_path, xvc_metadata) {
934                    Some(*xvc_entity)
935                } else {
936                    None
937                }
938            } else {
939                None
940            }
941        })
942        .collect()
943}