Skip to main content

rsynko_memory/
manager.rs

1use crate::{DownloadEvent, DownloadOptions, Format, FormatCatalog, FormatChoice, role_accepts};
2use crate::{RsyncSyntax, SyncCommand};
3use rsynko_manager::*;
4use rsynko_media::portable_file_name;
5use rsynko_rsync::{
6    RsyncEndpointExt, SyncCommandExt, SyncCommandViewAlg, SyncMode, SyncProfile, sync_profile,
7};
8use rsynko_ui::{DiscoveryState, FormatChoiceViewAlg, FormatLabelExt, FormatRolesExt};
9use std::ops::Not;
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13/// Identifies one queue entry independently of list position.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct QueueId(pub u64);
16
17/// Selects the deterministic in-memory interpretation of manager carriers.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub struct MemoryManager;
20
21/// Denotes one requested source and optional explicit output path.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct SourceRequest {
24    /// Names the source URL.
25    pub source: String,
26    /// Names an explicit output path when requested.
27    pub output: Option<PathBuf>,
28    /// Selects stream roles and format identity before execution begins.
29    pub options: DownloadOptions,
30}
31
32impl SourceRequest {
33    /// Constructs a source request with explicit output and download options.
34    #[must_use]
35    pub fn new(
36        source: impl Into<String>,
37        output: Option<PathBuf>,
38        options: DownloadOptions,
39    ) -> Self {
40        Self {
41            source: source.into(),
42            output,
43            options,
44        }
45    }
46}
47
48impl ManagerSorts for MemoryManager {
49    type Id = ();
50    type Source = SourceRequest;
51    type Options = DownloadOptions;
52    type Output = Option<PathBuf>;
53    type Format = Format;
54    type Change = PlannedChange;
55    type Entry = ();
56    type Downloads = ManagerState;
57}
58
59impl ManagerSorts for ManagerState {
60    type Id = QueueId;
61    type Source = SourceRequest;
62    type Options = DownloadOptions;
63    type Output = Option<PathBuf>;
64    type Format = Format;
65    type Change = PlannedChange;
66    type Entry = QueueEntry;
67    type Downloads = ();
68}
69
70impl DownloadsAlg for MemoryManager {
71    fn empty_downloads(&self) -> Self::Downloads {
72        ManagerState::downloads()
73    }
74}
75
76impl SourceRequestAlg for MemoryManager {
77    fn source(
78        &self,
79        input: impl Into<String>,
80        output: Self::Output,
81        options: Self::Options,
82    ) -> Self::Source {
83        SourceRequest::new(input, output, options)
84    }
85}
86
87impl MediaOptionsAlg for MemoryManager {
88    fn progressive(&self) -> Self::Options {
89        DownloadOptions::progressive()
90    }
91
92    fn audio(&self) -> Self::Options {
93        DownloadOptions::audio()
94    }
95
96    fn video(&self) -> Self::Options {
97        DownloadOptions::video()
98    }
99}
100
101impl SourceRecognitionAlg for MemoryManager {
102    fn recognizes_source(&self, line: &str) -> bool {
103        recognized(line.trim())
104    }
105}
106
107impl SubmissionAlg for MemoryManager {
108    fn submitted(&self, line: &str) -> Self::Source {
109        submitted(line)
110    }
111}
112
113impl OutputChoiceAlg for MemoryManager {
114    fn suggested_output(&self) -> Self::Output {
115        None
116    }
117
118    fn exact_output(&self, path: impl Into<PathBuf>) -> Self::Output {
119        Some(path.into())
120    }
121}
122
123impl SourceRequestAlg for ManagerState {
124    fn source(
125        &self,
126        input: impl Into<String>,
127        output: Self::Output,
128        options: Self::Options,
129    ) -> Self::Source {
130        SourceRequest::new(input, output, options)
131    }
132}
133
134impl MediaOptionsAlg for ManagerState {
135    fn progressive(&self) -> Self::Options {
136        DownloadOptions::progressive()
137    }
138
139    fn audio(&self) -> Self::Options {
140        DownloadOptions::audio()
141    }
142
143    fn video(&self) -> Self::Options {
144        DownloadOptions::video()
145    }
146}
147
148impl SourceRecognitionAlg for ManagerState {
149    fn recognizes_source(&self, line: &str) -> bool {
150        recognized(line.trim())
151    }
152}
153
154impl SubmissionAlg for ManagerState {
155    fn submitted(&self, line: &str) -> Self::Source {
156        submitted(line)
157    }
158}
159
160impl OutputChoiceAlg for ManagerState {
161    fn suggested_output(&self) -> Self::Output {
162        None
163    }
164
165    fn exact_output(&self, path: impl Into<PathBuf>) -> Self::Output {
166        Some(path.into())
167    }
168}
169
170/// Denotes the mechanism-independent state of one transfer.
171#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct TransferState {
173    phase: TransferPhase,
174    destination: Option<PathBuf>,
175    downloaded: u64,
176    total: Option<u64>,
177    elapsed: Duration,
178    terminal: Option<DownloadEvent>,
179    summary: Option<String>,
180    detail: Option<String>,
181    pause_supported: bool,
182    resume_phase: Option<TransferPhase>,
183}
184
185impl TransferState {
186    fn ready() -> Self {
187        Self {
188            phase: TransferPhase::Ready,
189            destination: None,
190            downloaded: 0,
191            total: None,
192            elapsed: Duration::ZERO,
193            terminal: None,
194            summary: None,
195            detail: None,
196            pause_supported: false,
197            resume_phase: None,
198        }
199    }
200
201    /// Observes the transfer phase.
202    #[must_use]
203    pub const fn phase(&self) -> TransferPhase {
204        self.phase
205    }
206
207    /// Observes the final destination when known.
208    #[must_use]
209    pub fn destination(&self) -> Option<&Path> {
210        self.destination.as_deref()
211    }
212
213    /// Observes retrieved bytes.
214    #[must_use]
215    pub const fn downloaded(&self) -> u64 {
216        self.downloaded
217    }
218
219    /// Observes expected bytes when known.
220    #[must_use]
221    pub const fn total(&self) -> Option<u64> {
222        self.total
223    }
224
225    /// Observes elapsed transfer time.
226    #[must_use]
227    pub const fn elapsed(&self) -> Duration {
228        self.elapsed
229    }
230
231    /// Observes the terminal download event when one occurred.
232    #[must_use]
233    pub const fn terminal(&self) -> Option<&DownloadEvent> {
234        self.terminal.as_ref()
235    }
236
237    /// Observes the concise user-facing failure summary.
238    #[must_use]
239    pub fn summary(&self) -> Option<&str> {
240        self.summary.as_deref()
241    }
242
243    /// Observes detailed diagnostics.
244    #[must_use]
245    pub fn detail(&self) -> Option<&str> {
246        self.detail.as_deref()
247    }
248}
249
250impl TransferViewAlg for TransferState {
251    fn transferred(&self) -> u64 {
252        self.downloaded
253    }
254
255    fn transfer_total(&self) -> Option<u64> {
256        self.total
257    }
258
259    fn transfer_elapsed(&self) -> Duration {
260        self.elapsed
261    }
262
263    fn transfer_complete(&self) -> bool {
264        self.phase == TransferPhase::Complete
265    }
266
267    fn transfer_destination(&self) -> Option<&Path> {
268        self.destination()
269    }
270
271    fn transfer_summary(&self) -> Option<&str> {
272        self.summary()
273    }
274
275    fn transfer_detail(&self) -> Option<&str> {
276        self.detail()
277    }
278}
279
280/// Denotes one source in the in-memory manager collection.
281#[derive(Clone, Debug, PartialEq)]
282pub struct QueueEntry {
283    id: QueueId,
284    request: SourceRequest,
285    transfer: TransferState,
286    formats: FormatCatalog,
287    media_id: Option<String>,
288    title: Option<String>,
289    output_custom: bool,
290    log: Vec<String>,
291    rehearsal: Rehearsal,
292}
293
294/// Denotes what rehearsal has stated about one request.
295#[derive(Clone, Debug, PartialEq, Eq)]
296pub enum Rehearsal {
297    /// Denotes a request no one has rehearsed.
298    Unrehearsed,
299    /// Denotes a rehearsal an interpreter is currently performing.
300    Rehearsing,
301    /// Denotes the changes a rehearsal stated, in the order it stated them.
302    Reported(Vec<PlannedChange>),
303    /// Denotes a rehearsal failure suitable for display.
304    Failed(String),
305}
306
307/// Denotes one change a rehearsal stated the transfer would make.
308#[derive(Clone, Debug, PartialEq, Eq, derive_new::new)]
309pub struct PlannedChange {
310    /// Names the path relative to the transferred folder.
311    pub path: String,
312    /// States what would happen to that path.
313    pub kind: ChangeKind,
314    /// States the byte count the change moves, when the rehearsal stated one.
315    pub size: Option<u64>,
316}
317
318impl PlannedChangeAlg for PlannedChange {
319    fn change_path(&self) -> &str {
320        &self.path
321    }
322
323    fn change_kind(&self) -> ChangeKind {
324        self.kind
325    }
326
327    fn change_size(&self) -> Option<u64> {
328        self.size
329    }
330}
331
332impl QueueEntry {
333    /// Observes stable queue identity.
334    #[must_use]
335    pub const fn id(&self) -> QueueId {
336        self.id
337    }
338
339    /// Observes the requested source.
340    #[must_use]
341    pub fn source(&self) -> &str {
342        &self.request.source
343    }
344
345    /// Observes the explicit output path when present.
346    #[must_use]
347    pub fn output(&self) -> Option<&Path> {
348        self.request.output.as_deref()
349    }
350
351    /// Observes the extracted media title when known.
352    #[must_use]
353    pub fn title(&self) -> Option<&str> {
354        self.title.as_deref()
355    }
356
357    /// Observes transfer state.
358    #[must_use]
359    pub const fn transfer(&self) -> &TransferState {
360        &self.transfer
361    }
362
363    /// Observes choices fixed when execution first starts.
364    #[must_use]
365    pub const fn options(&self) -> &DownloadOptions {
366        &self.request.options
367    }
368
369    /// Observes discovered selectable formats.
370    #[must_use]
371    pub const fn formats(&self) -> &FormatCatalog {
372        &self.formats
373    }
374
375    /// Observes whether request choices remain editable.
376    #[must_use]
377    pub fn is_editable(&self) -> bool {
378        self.transfer.phase == TransferPhase::Ready
379    }
380
381    /// Derives visible details controls from transfer state.
382    #[must_use]
383    pub fn detail_controls(&self) -> Vec<DetailControl> {
384        let rehearsed = matches!(self.rehearsal, Rehearsal::Unrehearsed)
385            .not()
386            .then_some(DetailControl::Report);
387        let rehearsal = self
388            .request
389            .options
390            .dry_run()
391            .is_some()
392            .then_some(DetailControl::DryRun);
393        // A request performed by naming a program states that command; one fetched states none.
394        let commanded = self
395            .stated_command()
396            .is_some()
397            .then_some(DetailControl::Command);
398        match self.transfer.phase {
399            TransferPhase::Ready => [
400                // An input already read cannot change: everything derived from it would be a lie.
401                (self.output_naming() == OutputNaming::Stated).then_some(DetailControl::Input),
402                Some(DetailControl::Output),
403                // A media item chooses a representation; a folder chooses a way of transferring.
404                (self.request.options.media_streams().is_some()
405                    || self.request.options.profile().is_some())
406                .then_some(DetailControl::Format),
407                commanded,
408                rehearsed,
409                Some(DetailControl::Log),
410                rehearsal,
411                Some(DetailControl::Duplicate),
412                Some(DetailControl::Delete),
413            ]
414            .into_iter()
415            .flatten()
416            .collect(),
417            // The cursor walks what is shown, and the record is a field above the actions.
418            TransferPhase::Failed => [
419                commanded,
420                rehearsed,
421                Some(DetailControl::Log),
422                Some(DetailControl::Restart),
423                Some(DetailControl::Duplicate),
424                Some(DetailControl::Delete),
425            ]
426            .into_iter()
427            .flatten()
428            .collect(),
429            TransferPhase::Rehearsing
430            | TransferPhase::Waiting
431            | TransferPhase::Extracting
432            | TransferPhase::Downloading
433            | TransferPhase::Paused
434            | TransferPhase::Publishing
435            | TransferPhase::Complete => [
436                commanded,
437                rehearsed,
438                Some(DetailControl::Log),
439                Some(DetailControl::Duplicate),
440                Some(DetailControl::Delete),
441            ]
442            .into_iter()
443            .flatten()
444            .collect(),
445        }
446    }
447
448    /// Derives the currently valid meaning of Space.
449    #[must_use]
450    pub fn space_action(&self) -> Option<SpaceAction> {
451        match self.transfer.phase {
452            TransferPhase::Ready if self.output().is_some() => Some(match self.dry_run() {
453                Some(true) => SpaceAction::Rehearse,
454                Some(false) | None => SpaceAction::Start,
455            }),
456            TransferPhase::Extracting | TransferPhase::Downloading
457                if self.transfer.pause_supported =>
458            {
459                Some(SpaceAction::Pause)
460            }
461            TransferPhase::Paused => Some(SpaceAction::Resume),
462            TransferPhase::Ready
463            | TransferPhase::Waiting
464            | TransferPhase::Rehearsing
465            | TransferPhase::Extracting
466            | TransferPhase::Downloading
467            | TransferPhase::Publishing
468            | TransferPhase::Complete
469            | TransferPhase::Failed => None,
470        }
471    }
472
473    /// States exactly the command an interpreter would run to perform this request.
474    ///
475    /// Only a transfer is performed by naming a program. The command is derived from the two
476    /// ends, the way of transferring, and the rehearsal mode, so changing any of them changes it.
477    /// An interpreter runs *this* command, and a reader reads *this* command: one derivation, so
478    /// what is shown and what is run cannot drift apart.
479    #[must_use]
480    pub fn transfer_command(&self) -> Option<SyncCommand> {
481        let dry_run = self.request.options.dry_run()?;
482        let destination = self.output()?;
483        let mode = SyncMode::transfer()
484            .rehearsed(dry_run)
485            .profiled(self.request.options.profile()?);
486        Some(RsyncSyntax.transfer_command(
487            &RsyncSyntax.read_endpoint(&self.request.source),
488            &RsyncSyntax.read_endpoint(&destination.display().to_string()),
489            mode,
490        ))
491    }
492
493    /// States what an interpreter would run, as a reader reads it.
494    #[must_use]
495    pub fn stated_command(&self) -> Option<String> {
496        let command = self.transfer_command()?;
497        Some(format!(
498            "{} {}",
499            RsyncSyntax.command_program(&command),
500            RsyncSyntax
501                .command_arguments(&command)
502                .collect::<Vec<_>>()
503                .join(" ")
504        ))
505    }
506
507    /// Observes the notes stated about this request, in the order they were stated.
508    pub fn log(&self) -> impl DoubleEndedIterator<Item = &str> {
509        self.log.iter().map(String::as_str)
510    }
511
512    /// States one note about this request.
513    fn note(&mut self, note: impl Into<String>) {
514        self.log.push(note.into());
515    }
516
517    /// Forgets what a rehearsal stated, because it no longer states what would happen.
518    ///
519    /// A report describes one command. Change what would be run — either end, or the way of
520    /// running it — and the report describes a command nobody is going to run.
521    fn forget_rehearsal(&mut self) {
522        if matches!(self.rehearsal, Rehearsal::Unrehearsed) {
523            return;
524        }
525        self.rehearsal = Rehearsal::Unrehearsed;
526        self.note("the report was forgotten: it no longer states what would happen");
527    }
528
529    /// Derives the source input before inspection and the media title afterward.
530    #[must_use]
531    pub fn label(&self) -> &str {
532        self.title
533            .as_deref()
534            .filter(|title| !title.is_empty())
535            .unwrap_or(&self.request.source)
536    }
537}
538
539impl TransferViewAlg for QueueEntry {
540    fn transferred(&self) -> u64 {
541        self.transfer.transferred()
542    }
543
544    fn transfer_total(&self) -> Option<u64> {
545        self.transfer.transfer_total()
546    }
547
548    fn transfer_elapsed(&self) -> Duration {
549        self.transfer.transfer_elapsed()
550    }
551
552    fn transfer_complete(&self) -> bool {
553        self.transfer.transfer_complete()
554    }
555
556    fn transfer_destination(&self) -> Option<&Path> {
557        self.transfer.transfer_destination()
558    }
559
560    fn transfer_summary(&self) -> Option<&str> {
561        self.transfer.transfer_summary()
562    }
563
564    fn transfer_detail(&self) -> Option<&str> {
565        self.transfer.transfer_detail()
566    }
567}
568
569impl QueueEntryAlg for QueueEntry {
570    fn label(&self) -> &str {
571        self.label()
572    }
573
574    fn source(&self) -> &str {
575        self.source()
576    }
577
578    fn phase(&self) -> TransferPhase {
579        self.transfer.phase()
580    }
581
582    fn dry_run(&self) -> Option<bool> {
583        self.request.options.dry_run()
584    }
585
586    fn output_naming(&self) -> OutputNaming {
587        // A transferred path is stated by whoever asked for it; a downloaded file is named here.
588        if self.request.options.profile().is_some() {
589            OutputNaming::Stated
590        } else {
591            OutputNaming::Portable
592        }
593    }
594
595    fn stated_command(&self) -> Option<String> {
596        self.stated_command()
597    }
598
599    fn output(&self) -> Option<&Path> {
600        self.output()
601    }
602
603    fn is_editable(&self) -> bool {
604        self.is_editable()
605    }
606
607    fn detail_controls(&self) -> Vec<DetailControl> {
608        self.detail_controls()
609    }
610
611    fn download_log(&self) -> impl Iterator<Item = &str> {
612        self.log()
613    }
614
615    fn space_action(&self) -> Option<SpaceAction> {
616        self.space_action()
617    }
618}
619
620impl FormatChoiceViewAlg for QueueEntry {
621    type Format = Format;
622
623    fn discovery(&self) -> DiscoveryState<'_> {
624        match &self.formats {
625            FormatCatalog::Unknown => DiscoveryState::Unrequested,
626            FormatCatalog::Waiting => DiscoveryState::Waiting,
627            FormatCatalog::Inspecting => DiscoveryState::Inspecting,
628            FormatCatalog::Available(_) => DiscoveryState::Described,
629            FormatCatalog::Failed(message) => DiscoveryState::Failed(message),
630        }
631    }
632
633    fn described_formats(&self) -> impl Iterator<Item = &Self::Format> {
634        self.formats.available().unwrap_or_default().iter()
635    }
636}
637
638impl RehearsalViewAlg for QueueEntry {
639    type Change = PlannedChange;
640
641    fn rehearsal(&self) -> RehearsalState<'_> {
642        match &self.rehearsal {
643            Rehearsal::Unrehearsed => RehearsalState::Unrehearsed,
644            Rehearsal::Rehearsing => RehearsalState::Rehearsing,
645            Rehearsal::Reported(_) => RehearsalState::Reported,
646            Rehearsal::Failed(message) => RehearsalState::Failed(message),
647        }
648    }
649
650    fn planned_changes(&self) -> impl Iterator<Item = &Self::Change> {
651        match &self.rehearsal {
652            Rehearsal::Reported(changes) => changes.as_slice(),
653            Rehearsal::Unrehearsed | Rehearsal::Rehearsing | Rehearsal::Failed(_) => &[],
654        }
655        .iter()
656    }
657}
658
659impl RequestOptionsAlg for QueueEntry {
660    fn performer(&self) -> Performer {
661        self.request.options.performer()
662    }
663
664    fn media_streams(&self) -> Option<MediaStreams> {
665        self.request.options.media_streams()
666    }
667
668    fn chosen_choice(&self) -> Option<&str> {
669        // A folder always transfers one stated way; a media item may leave its format to ranking.
670        if let Some(profile) = self.request.options.profile() {
671            return Some(sync_profile::to(profile));
672        }
673        match self.request.options.format() {
674            FormatChoice::Best => None,
675            FormatChoice::Id(id) => Some(id),
676        }
677    }
678
679    fn selectable_choices(&self) -> impl Iterator<Item = &str> {
680        let ways = self
681            .request
682            .options
683            .profile()
684            .map(|_| sync_profile::ALL)
685            .unwrap_or_default()
686            .iter()
687            .copied()
688            .map(sync_profile::to)
689            .collect::<Vec<_>>();
690        ways.into_iter().chain(
691            self.formats
692                .available()
693                .unwrap_or_default()
694                .iter()
695                .map(|format| format.id.as_str()),
696        )
697    }
698
699    fn choice_summary(&self, choice: &str) -> Option<&str> {
700        // A described format is read through what it carries; a way of transferring says what it
701        // does, because nothing else about it would tell a reader.
702        sync_profile::from(choice).map(SyncProfile::summary)
703    }
704}
705
706/// Denotes the complete renderer-neutral manager application state.
707#[derive(Clone, Debug, PartialEq)]
708pub struct ManagerState {
709    page: ManagerPage<QueueId>,
710    queue: Vec<QueueEntry>,
711    selected: Option<QueueId>,
712    detail_control: Option<DetailControl>,
713    draft: String,
714    draft_cursor: usize,
715    output_draft: String,
716    output_draft_cursor: usize,
717    input_draft: String,
718    input_draft_cursor: usize,
719    message: Option<String>,
720    exit_requested: bool,
721    next_id: u64,
722}
723
724impl ManagerState {
725    /// Constructs the downloads collection page.
726    #[must_use]
727    pub const fn downloads() -> Self {
728        Self {
729            page: ManagerPage::Collection,
730            queue: Vec::new(),
731            selected: None,
732            detail_control: None,
733            draft: String::new(),
734            draft_cursor: 0,
735            output_draft: String::new(),
736            output_draft_cursor: 0,
737            input_draft: String::new(),
738            input_draft_cursor: 0,
739            message: None,
740            exit_requested: false,
741            next_id: 0,
742        }
743    }
744
745    /// Observes the current page.
746    #[must_use]
747    pub const fn page(&self) -> ManagerPage<QueueId> {
748        self.page
749    }
750
751    /// Observes queue entries in collection order.
752    #[must_use]
753    pub fn queue(&self) -> &[QueueEntry] {
754        &self.queue
755    }
756
757    /// Observes the selected stable identity.
758    #[must_use]
759    pub const fn selected_id(&self) -> Option<QueueId> {
760        self.selected
761    }
762
763    /// Observes the selected visible details control.
764    #[must_use]
765    pub const fn detail_control(&self) -> Option<DetailControl> {
766        self.detail_control
767    }
768
769    /// Observes the selected entry.
770    #[must_use]
771    pub fn selected(&self) -> Option<&QueueEntry> {
772        let selected = self.selected?;
773        self.entry(selected)
774    }
775
776    /// Observes one entry by stable identity.
777    #[must_use]
778    pub fn entry(&self, id: QueueId) -> Option<&QueueEntry> {
779        self.queue.iter().find(|entry| entry.id == id)
780    }
781
782    /// Observes the add-sources editor draft.
783    #[must_use]
784    pub fn draft(&self) -> &str {
785        &self.draft
786    }
787
788    /// Observes the output-file-name editor draft.
789    #[must_use]
790    pub fn output_draft(&self) -> &str {
791        &self.output_draft
792    }
793
794    /// Observes the manager status message.
795    #[must_use]
796    pub fn message(&self) -> Option<&str> {
797        self.message.as_deref()
798    }
799
800    /// Observes all waiting entries in collection order.
801    pub fn waiting(&self) -> impl Iterator<Item = &QueueEntry> {
802        self.queue
803            .iter()
804            .filter(|entry| entry.transfer.phase == TransferPhase::Waiting)
805    }
806
807    /// Observes the first format inspection waiting for an interpreter.
808    #[must_use]
809    pub fn first_waiting_format_catalog(&self) -> Option<&QueueEntry> {
810        self.queue
811            .iter()
812            .find(|entry| entry.formats == FormatCatalog::Waiting)
813    }
814
815    /// Observes whether any transfer is active.
816    #[must_use]
817    pub fn has_active_transfer(&self) -> bool {
818        self.queue.iter().any(|entry| {
819            matches!(
820                entry.transfer.phase,
821                TransferPhase::Extracting
822                    | TransferPhase::Downloading
823                    | TransferPhase::Paused
824                    | TransferPhase::Publishing
825            )
826        })
827    }
828}
829
830impl DownloadCollectionAlg for ManagerState {
831    fn add_sources(mut self, sources: impl IntoIterator<Item = Self::Source>) -> Self {
832        let requests: Vec<Self::Source> = sources.into_iter().collect();
833        self.apply_manager_event(ManagerIntentOp::AddSources { requests });
834        self
835    }
836}
837
838impl NavigationStateAlg for ManagerState {
839    fn page(&self) -> ManagerPage<Self::Id> {
840        self.page
841    }
842
843    fn set_page(&mut self, page: ManagerPage<Self::Id>) {
844        self.page = page;
845    }
846}
847
848impl QueueCatalogAlg for ManagerState {
849    fn queue_ids(&self) -> impl Iterator<Item = Self::Id> {
850        self.queue.iter().map(|entry| entry.id)
851    }
852
853    fn selected_queue_id(&self) -> Option<Self::Id> {
854        self.selected
855    }
856
857    fn set_selected_queue_id(&mut self, selected: Option<Self::Id>) {
858        self.selected = selected;
859    }
860
861    fn queue_entry(&self, id: Self::Id) -> Option<&Self::Entry> {
862        self.entry(id)
863    }
864}
865
866impl QueueAppendAlg for ManagerState {
867    fn append_sources(&mut self, requests: Vec<Self::Source>) -> Vec<Self::Id> {
868        requests
869            .into_iter()
870            .map(|request| {
871                let id = QueueId(self.next_id);
872                self.next_id = self.next_id.saturating_add(1);
873                let output_custom = request.output.is_some();
874                let request_source = request.source.clone();
875                let formats = if request.options.performer() == Performer::Retrieval {
876                    FormatCatalog::Waiting
877                } else {
878                    FormatCatalog::Unknown
879                };
880                self.queue.push(QueueEntry {
881                    id,
882                    request,
883                    transfer: TransferState::ready(),
884                    formats,
885                    media_id: None,
886                    title: None,
887                    output_custom,
888                    log: vec![format!("added {}", request_source)],
889                    rehearsal: Rehearsal::Unrehearsed,
890                });
891                refresh_default_output(self.queue.last_mut().expect("the request was appended"));
892                id
893            })
894            .collect()
895    }
896}
897
898impl QueueRemoveAlg for ManagerState {
899    fn remove_queue_entry(&mut self, id: Self::Id) {
900        self.queue.retain(|entry| entry.id != id);
901    }
902}
903
904impl QueuePauseAlg for ManagerState {
905    fn toggle_queue_pause(&mut self, id: Self::Id) {
906        let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) else {
907            return;
908        };
909        match entry.space_action() {
910            Some(SpaceAction::Pause) => {
911                entry.transfer.resume_phase = Some(entry.transfer.phase);
912                entry.transfer.phase = TransferPhase::Paused;
913            }
914            Some(SpaceAction::Resume) => {
915                entry.transfer.phase = entry
916                    .transfer
917                    .resume_phase
918                    .take()
919                    .unwrap_or(TransferPhase::Downloading);
920            }
921            // Pausing names an active transfer; starting and rehearsing are not that.
922            Some(SpaceAction::Start | SpaceAction::Rehearse) | None => {}
923        }
924    }
925}
926
927impl QueueDuplicateAlg for ManagerState {
928    fn duplicate_queue_entry(&mut self, id: Self::Id) -> Option<Self::Id> {
929        let entry = self.queue.iter().find(|entry| entry.id == id)?.clone();
930        let source = entry.source().to_owned();
931        // A fresh request states what it would do before it does it, however it came to exist.
932        let options = entry.request.options.clone().rehearsing();
933        // A transfer is the pair of ends it names, so duplicating one keeps both. A download
934        // produces a file of its own, and two of them may not produce the same one.
935        let output = entry
936            .request
937            .options
938            .profile()
939            .and(entry.request.output.clone());
940        let stated = output.is_some();
941        let formats = match entry.formats {
942            FormatCatalog::Available(formats) => FormatCatalog::Available(formats),
943            FormatCatalog::Unknown
944            | FormatCatalog::Waiting
945            | FormatCatalog::Inspecting
946            | FormatCatalog::Failed(_) => FormatCatalog::Unknown,
947        };
948        let duplicate = QueueId(self.next_id);
949        self.next_id = self.next_id.saturating_add(1);
950        self.queue.push(QueueEntry {
951            id: duplicate,
952            request: SourceRequest::new(source, output, options),
953            transfer: TransferState::ready(),
954            formats,
955            media_id: entry.media_id,
956            title: entry.title,
957            output_custom: stated,
958            log: vec![format!("duplicated from download {}", entry.id.0)],
959            rehearsal: Rehearsal::Unrehearsed,
960        });
961        refresh_default_output(self.queue.last_mut().expect("duplicate was appended"));
962        Some(duplicate)
963    }
964}
965
966impl QueueFormatEditAlg for ManagerState {
967    fn cycle_queue_format(&mut self, id: Self::Id, forward: bool) {
968        let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) else {
969            return;
970        };
971        if !entry.is_editable() {
972            return;
973        }
974        // A folder's choices are the ways it may be transferred, and nothing else.
975        if entry.request.options.profile().is_some() {
976            let ways = sync_profile::ALL;
977            let current = entry
978                .request
979                .options
980                .profile()
981                .and_then(|profile| ways.iter().position(|way| *way == profile))
982                .unwrap_or(0);
983            let next = if forward {
984                (current + 1) % ways.len()
985            } else if current == 0 {
986                ways.len() - 1
987            } else {
988                current - 1
989            };
990            entry.request.options = entry.request.options.clone().with_profile(ways[next]);
991            entry.forget_rehearsal();
992            return;
993        }
994        // One list states the choices: prefer a role, or fix one identity extraction discovered.
995        // A request choosing no media role offers no roles to cycle through, only identities.
996        let offered = entry
997            .request
998            .options
999            .media_streams()
1000            .map(|_| entry.offered_streams())
1001            .unwrap_or_default();
1002        let choices: Vec<DownloadOptions> = offered
1003            .into_iter()
1004            .map(|streams| entry.request.options.clone().with_media_streams(streams))
1005            .chain(
1006                entry
1007                    .formats
1008                    .available()
1009                    .unwrap_or_default()
1010                    .iter()
1011                    .map(|format| {
1012                        entry
1013                            .request
1014                            .options
1015                            .clone()
1016                            .with_format(FormatChoice::Id(format.id.clone()))
1017                    }),
1018            )
1019            .collect();
1020        let current = choices
1021            .iter()
1022            .position(|choice| *choice == entry.request.options)
1023            .unwrap_or(0);
1024        let next = if forward {
1025            (current + 1) % choices.len()
1026        } else if current == 0 {
1027            choices.len() - 1
1028        } else {
1029            current - 1
1030        };
1031        entry.request.options = choices[next].clone();
1032        refresh_default_output(entry);
1033    }
1034}
1035
1036impl FormatCatalogStateAlg for ManagerState {
1037    fn request_format_catalog(&mut self, id: Self::Id) {
1038        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id)
1039            && entry.is_editable()
1040            // A folder has nothing to inspect: the ways it may be transferred are always offered.
1041            && entry.request.options.performer() == Performer::Retrieval
1042            && matches!(
1043                entry.formats,
1044                FormatCatalog::Unknown | FormatCatalog::Failed(_)
1045            )
1046        {
1047            entry.formats = FormatCatalog::Waiting;
1048        }
1049    }
1050
1051    fn apply_format_catalog_event(&mut self, id: Self::Id, event: FormatDiscoveryOp<Self::Format>) {
1052        let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) else {
1053            return;
1054        };
1055        entry.formats = match event {
1056            FormatDiscoveryOp::Started {} => {
1057                entry.note("inspecting source formats");
1058                FormatCatalog::Inspecting
1059            }
1060            FormatDiscoveryOp::Available { formats } => {
1061                entry.note(format!("extraction described {} formats", formats.len()));
1062                for format in &formats {
1063                    entry.note(described_format(format));
1064                }
1065                FormatCatalog::Available(formats)
1066            }
1067            FormatDiscoveryOp::Failed { message } => {
1068                entry.note(format!("format inspection failed: {message}"));
1069                FormatCatalog::Failed(message)
1070            }
1071        };
1072        // A request cannot go on asking for something the source turned out not to have. A tweet
1073        // states one file carrying both streams, so sound alone is not among the things it holds;
1074        // one carrying only pictures states no streams at all, and is asked for by name instead.
1075        // A request that already named what it wants is left alone: it asked for that.
1076        if matches!(entry.request.options.format(), FormatChoice::Best)
1077            && let Some(asked) = entry.request.options.media_streams()
1078        {
1079            let offered = entry.offered_streams();
1080            if let Some(carried) = offered.first().copied() {
1081                if !offered.contains(&asked) {
1082                    entry.request.options =
1083                        entry.request.options.clone().with_media_streams(carried);
1084                }
1085            } else if let Some(named) = entry
1086                .formats
1087                .available()
1088                .and_then(|described| described.first())
1089                .map(|format| format.id.clone())
1090            {
1091                entry.request.options = entry
1092                    .request
1093                    .options
1094                    .clone()
1095                    .with_format(FormatChoice::Id(named));
1096            }
1097        }
1098        refresh_default_output(entry);
1099    }
1100}
1101
1102impl OutputDraftAlg for ManagerState {
1103    fn output_draft(&self) -> &str {
1104        &self.output_draft
1105    }
1106
1107    fn set_output_draft(&mut self, draft: String) {
1108        self.output_draft_cursor = draft.len();
1109        self.output_draft = draft;
1110    }
1111}
1112
1113impl InputDraftAlg for ManagerState {
1114    fn input_draft(&self) -> &str {
1115        &self.input_draft
1116    }
1117
1118    fn set_input_draft(&mut self, draft: String) {
1119        self.input_draft_cursor = draft.len();
1120        self.input_draft = draft;
1121    }
1122}
1123
1124impl QueueSourceAlg for ManagerState {
1125    fn set_queue_source(&mut self, id: Self::Id, source: String) {
1126        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id)
1127            && entry.is_editable()
1128        {
1129            entry.note(format!("input changed to {source}"));
1130            entry.request.source = source;
1131            entry.forget_rehearsal();
1132            refresh_default_output(entry);
1133        }
1134    }
1135}
1136
1137impl QueueOutputAlg for ManagerState {
1138    fn set_queue_output(&mut self, id: Self::Id, output: PathBuf) {
1139        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id)
1140            && entry.is_editable()
1141        {
1142            entry.request.output = Some(output);
1143            entry.output_custom = true;
1144            entry.forget_rehearsal();
1145        }
1146    }
1147}
1148
1149impl DownloadLogAlg for ManagerState {
1150    fn note_download(&mut self, id: Self::Id, note: String) {
1151        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) {
1152            entry.note(note);
1153        }
1154    }
1155}
1156
1157impl SourceMetadataAlg for ManagerState {
1158    fn apply_source_metadata(&mut self, id: Self::Id, media_id: String, title: Option<String>) {
1159        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) {
1160            entry.note(match &title {
1161                Some(title) => format!("extracted {media_id}: {title}"),
1162                None => format!("extracted {media_id}"),
1163            });
1164            entry.media_id = Some(media_id);
1165            entry.title = title;
1166            refresh_default_output(entry);
1167        }
1168    }
1169}
1170
1171fn refresh_default_output(entry: &mut QueueEntry) {
1172    if entry.output_custom {
1173        return;
1174    }
1175    // A transfer naming only where it comes from comes to rest under the name it already has.
1176    // Naming a folder is safer than naming the one somebody happens to be standing in, which is
1177    // what mirroring into it would empty.
1178    if entry.request.options.performer() == Performer::Program {
1179        let source = RsyncSyntax.read_endpoint(&entry.request.source);
1180        entry.request.output = Some(PathBuf::from(RsyncSyntax.endpoint_name(&source)));
1181        return;
1182    }
1183    let Some(media_id) = entry.media_id.as_deref() else {
1184        return;
1185    };
1186    entry.request.output = Some(portable_file_name(
1187        entry.title.as_deref(),
1188        media_id,
1189        selected_extension(entry),
1190    ));
1191}
1192
1193fn selected_extension(entry: &QueueEntry) -> Option<&str> {
1194    let formats = entry.formats.available()?;
1195    match entry.request.options.format() {
1196        FormatChoice::Best => formats
1197            .iter()
1198            .rev()
1199            .find(|format| {
1200                entry
1201                    .request
1202                    .options
1203                    .media_streams()
1204                    .is_some_and(|streams| role_accepts(streams, format))
1205            })
1206            .and_then(Format::extension),
1207        FormatChoice::Id(id) => formats
1208            .iter()
1209            .find(|format| format.id == *id)
1210            .and_then(Format::extension),
1211    }
1212}
1213
1214impl TransferStateAlg for ManagerState {
1215    fn set_waiting(&mut self, id: Self::Id) {
1216        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id)
1217            && entry.is_editable()
1218            && entry.output().is_some()
1219        {
1220            entry.transfer = TransferState::ready();
1221            entry.transfer.phase = TransferPhase::Waiting;
1222        }
1223    }
1224
1225    fn restart_waiting(&mut self, id: Self::Id) {
1226        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id)
1227            && entry.transfer.phase == TransferPhase::Failed
1228        {
1229            entry.transfer = TransferState::ready();
1230            entry.transfer.phase = TransferPhase::Waiting;
1231        }
1232    }
1233
1234    fn apply_transfer_event(&mut self, id: Self::Id, event: TransferObservationOp) {
1235        let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) else {
1236            return;
1237        };
1238        event.interpret(&mut EntryObservation(entry));
1239    }
1240}
1241
1242/// Applies one transfer observation to the entry it addresses.
1243///
1244/// The dispatch is the generated fold, so this states only what each observation *means*.
1245struct EntryObservation<'a>(&'a mut QueueEntry);
1246
1247impl TransferObservationInterpreter for EntryObservation<'_> {
1248    fn pause_capability(&mut self, supported: bool) {
1249        self.0.transfer.pause_supported = supported;
1250    }
1251
1252    fn started(&mut self) {
1253        self.0.note("retrieval started");
1254        self.0.transfer.phase = TransferPhase::Extracting;
1255    }
1256
1257    fn elapsed(&mut self, elapsed: Duration) {
1258        self.0.transfer.elapsed = elapsed;
1259    }
1260
1261    fn progress(&mut self, destination: PathBuf, downloaded: u64, total: Option<u64>) {
1262        let observed_phase = if total.is_some_and(|total| downloaded >= total) {
1263            TransferPhase::Publishing
1264        } else {
1265            TransferPhase::Downloading
1266        };
1267        if self.0.transfer.phase == TransferPhase::Paused {
1268            self.0.transfer.resume_phase = Some(observed_phase);
1269        } else {
1270            self.0.transfer.phase = observed_phase;
1271        }
1272        self.0.transfer.destination = Some(destination);
1273        self.0.transfer.downloaded = downloaded;
1274        self.0.transfer.total = total;
1275    }
1276
1277    fn completed(&mut self, destination: PathBuf, bytes: u64) {
1278        self.0.note(format!(
1279            "published {} ({bytes} bytes)",
1280            destination.display()
1281        ));
1282        self.0.transfer.phase = TransferPhase::Complete;
1283        self.0.transfer.terminal = Some(DownloadEvent::Succeeded { destination, bytes });
1284    }
1285
1286    fn failed(&mut self, destination: PathBuf, message: String) {
1287        self.0.note(format!(
1288            "retrieval failed at {}: {message}",
1289            destination.display()
1290        ));
1291        self.0.transfer.phase = TransferPhase::Failed;
1292        self.0.transfer.terminal = Some(DownloadEvent::Failed {
1293            destination,
1294            message,
1295        });
1296    }
1297
1298    fn program_failed(&mut self, summary: String, detail: String) {
1299        self.0.note(format!("{summary}: {detail}"));
1300        self.0.transfer.phase = TransferPhase::Failed;
1301        self.0.transfer.summary = Some(summary);
1302        self.0.transfer.detail = Some(detail);
1303    }
1304}
1305
1306impl ManagerState {
1307    /// Makes one request a transfer, the way a submitted line naming two ends does.
1308    ///
1309    /// A submitted line states what a request is; this is how a test states one without a line
1310    /// that would.
1311    pub fn transfer_request(&mut self, id: QueueId) {
1312        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) {
1313            entry.request.options = DownloadOptions::transfer();
1314        }
1315    }
1316}
1317
1318impl QueueDryRunAlg for ManagerState {
1319    fn set_queue_dry_run(&mut self, id: Self::Id, dry_run: bool) {
1320        if let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id)
1321            && entry.is_editable()
1322        {
1323            entry.request.options.set_dry_run(dry_run);
1324            entry.note(if dry_run {
1325                "dry run enabled: the next run will state what it would do and change nothing"
1326            } else {
1327                "dry run disabled: the next run will perform the transfer"
1328            });
1329        }
1330    }
1331}
1332
1333impl RehearsalStateAlg for ManagerState {
1334    fn apply_rehearsal_event(&mut self, id: Self::Id, event: RehearsalObservationOp<Self::Change>) {
1335        let Some(entry) = self.queue.iter_mut().find(|entry| entry.id == id) else {
1336            return;
1337        };
1338        match event {
1339            RehearsalObservationOp::Started {} => {
1340                entry.note("rehearsing what the transfer would do");
1341                entry.transfer.phase = TransferPhase::Rehearsing;
1342                entry.rehearsal = Rehearsal::Rehearsing;
1343            }
1344            RehearsalObservationOp::Reported { changes } => {
1345                let altered = changes.iter().filter(|change| change.kind.alters()).count();
1346                entry.note(format!(
1347                    "the rehearsal stated {altered} of {} paths would change",
1348                    changes.len()
1349                ));
1350                // A rehearsal is not a start: the request stays exactly as editable as it was.
1351                entry.transfer.phase = TransferPhase::Ready;
1352                entry.rehearsal = Rehearsal::Reported(changes);
1353            }
1354            RehearsalObservationOp::Failed { message } => {
1355                entry.note(format!("rehearsal failed: {message}"));
1356                entry.transfer.phase = TransferPhase::Ready;
1357                entry.rehearsal = Rehearsal::Failed(message);
1358            }
1359        }
1360    }
1361}
1362
1363impl DetailSelectionAlg for ManagerState {
1364    fn selected_detail_control(&self) -> Option<DetailControl> {
1365        self.detail_control
1366    }
1367
1368    fn set_selected_detail_control(&mut self, control: Option<DetailControl>) {
1369        self.detail_control = control;
1370    }
1371}
1372
1373impl DraftStateAlg for ManagerState {
1374    fn draft(&self) -> &str {
1375        &self.draft
1376    }
1377
1378    fn set_draft(&mut self, draft: String) {
1379        self.draft_cursor = draft.len();
1380        self.draft = draft;
1381    }
1382}
1383
1384impl TextEditorStateAlg for ManagerState {
1385    fn active_text_editor(&self) -> Option<(&str, usize)> {
1386        match self.page {
1387            ManagerPage::AddSources => Some((&self.draft, self.draft_cursor)),
1388            ManagerPage::Output(_) => Some((&self.output_draft, self.output_draft_cursor)),
1389            ManagerPage::Input(_) => Some((&self.input_draft, self.input_draft_cursor)),
1390            ManagerPage::Collection
1391            | ManagerPage::Details(_)
1392            | ManagerPage::Formats(_)
1393            | ManagerPage::Log(_)
1394            | ManagerPage::Report(_)
1395            | ManagerPage::Command(_) => None,
1396        }
1397    }
1398
1399    fn set_active_text_editor(&mut self, text: String, cursor: usize) {
1400        let mut cursor = cursor.min(text.len());
1401        while !text.is_char_boundary(cursor) {
1402            cursor = cursor.saturating_sub(1);
1403        }
1404        match self.page {
1405            ManagerPage::AddSources => {
1406                self.draft = text;
1407                self.draft_cursor = cursor;
1408            }
1409            ManagerPage::Output(_) => {
1410                self.output_draft = text;
1411                self.output_draft_cursor = cursor;
1412            }
1413            ManagerPage::Input(_) => {
1414                self.input_draft = text;
1415                self.input_draft_cursor = cursor;
1416            }
1417            ManagerPage::Collection
1418            | ManagerPage::Details(_)
1419            | ManagerPage::Formats(_)
1420            | ManagerPage::Log(_)
1421            | ManagerPage::Report(_)
1422            | ManagerPage::Command(_) => {}
1423        }
1424    }
1425}
1426
1427impl ManagerStatusAlg for ManagerState {
1428    fn manager_message(&self) -> Option<&str> {
1429        self.message()
1430    }
1431
1432    fn set_manager_message(&mut self, message: Option<String>) {
1433        self.message = message;
1434    }
1435}
1436
1437impl SafeExitAlg for ManagerState {
1438    fn exit_requested(&self) -> bool {
1439        self.exit_requested
1440    }
1441
1442    fn request_safe_exit(&mut self) {
1443        self.exit_requested = true;
1444    }
1445}
1446
1447/// States what one submitted line names.
1448///
1449/// A line naming two ends names a transfer between them. A line naming one end names a transfer
1450/// when that end is on another machine or ends with the separator a folder ends with, and a media
1451/// item otherwise. What is transferred may be one file or a whole folder; either way it is
1452/// rehearsed before it is performed, while a media item is extracted and downloaded.
1453fn submitted(line: &str) -> SourceRequest {
1454    let trimmed = line.trim();
1455    // A line some source claims is retrieved from it; everything else is a path, whether it names
1456    // one end or two.
1457    if recognized(trimmed) {
1458        return SourceRequest::new(trimmed, None, DownloadOptions::progressive());
1459    }
1460    if let Some((source, destination)) = RsyncSyntax.read_transfer(line) {
1461        return SourceRequest::new(
1462            RsyncSyntax.endpoint_text(&source),
1463            Some(PathBuf::from(RsyncSyntax.endpoint_text(&destination))),
1464            DownloadOptions::transfer(),
1465        );
1466    }
1467    SourceRequest::new(trimmed, None, DownloadOptions::transfer())
1468}
1469
1470/// Names the schemes a source fetches from, rather than paths a transfer walks.
1471///
1472/// Youtube reads its own watch, shortened, embedded, and short-form links out of a web address;
1473/// the deterministic fixture names its own scheme; and direct retrieval reads any other web
1474/// address. Nothing else here is a scheme: a host and a path, a folder, and a file are all paths.
1475const RETRIEVED_SCHEMES: [&str; 3] = ["https://", "http://", "fixture://"];
1476
1477/// States whether one submitted line names something a source retrieves for itself.
1478///
1479/// This application transfers paths. A line reads as anything else only because a source claimed
1480/// it, so `/home/dev/music`, `nas.local:/srv/data`, and `rsync://nas.local/data` are transfers,
1481/// and adding a source to this composition is adding what it recognizes here.
1482fn recognized(line: &str) -> bool {
1483    RETRIEVED_SCHEMES
1484        .iter()
1485        .any(|scheme| line.starts_with(scheme))
1486}
1487
1488/// States one described format the way the record reads it back.
1489fn described_format(format: &Format) -> String {
1490    format!("  {}", format.format_label())
1491}