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