Skip to main content

muster/application/
reconciliation.rs

1use std::collections::HashMap;
2
3use getset::Getters;
4use typed_builder::TypedBuilder;
5
6use crate::{
7    application::Workspace,
8    domain::{
9        config::{ProcessSpec, WorkspaceConfig},
10        process::{Process, ProcessKind, ProcessOrigin, RestartPolicy, StopPolicy},
11        value::{CommandLine, Description, PaneId, ProcessName},
12    },
13};
14
15/// Applies a workspace configuration to the live process model without I/O.
16#[derive(Getters, TypedBuilder)]
17#[getset(get = "pub")]
18pub struct Reconciliation {
19    /// Rebuilt workspace preserving every surviving process identity.
20    workspace: Workspace,
21    /// Configured panes that continue to be represented by disk configuration.
22    tracked: Vec<PaneId>,
23    /// Live panes removed from disk that must retire after their current exit.
24    retiring: Vec<PaneId>,
25    /// Stopped panes that should be discarded immediately.
26    removed: Vec<PaneId>,
27}
28
29/// Identifies a configured process occurrence by its fully resolved settings.
30#[derive(Clone)]
31pub struct ProcessSpecMatcher {
32    kind: ProcessKind,
33    name: ProcessName,
34    command: Option<CommandLine>,
35    working_dir: Option<std::path::PathBuf>,
36    description: Option<Description>,
37    restart: RestartPolicy,
38    stop: Option<StopPolicy>,
39    autostart: bool,
40}
41
42impl ProcessSpecMatcher {
43    /// Builds the resolved identity of `spec` in a config section.
44    pub fn of_spec(kind: ProcessKind, spec: &ProcessSpec) -> Self {
45        Self {
46            kind,
47            name: spec.name().clone(),
48            command: spec.command().clone(),
49            working_dir: spec.working_dir().clone(),
50            description: spec.description().clone(),
51            restart: spec.restart_policy(),
52            stop: spec.effective_stop_policy(kind),
53            autostart: spec.should_autostart(kind),
54        }
55    }
56
57    /// Builds the resolved identity represented by one live process.
58    pub fn of(process: &Process) -> Self {
59        Self {
60            kind: *process.kind(),
61            name: process.name().clone(),
62            command: process.command().clone(),
63            working_dir: process.working_dir().clone(),
64            description: process.description().clone(),
65            restart: *process.restart(),
66            stop: process.effective_stop_policy(),
67            autostart: *process.autostart(),
68        }
69    }
70
71    /// Returns whether `spec` resolves to this process occurrence's identity.
72    pub fn matches(&self, spec: &ProcessSpec) -> bool {
73        spec.name() == &self.name
74            && spec.command() == &self.command
75            && spec.working_dir() == &self.working_dir
76            && spec.description() == &self.description
77            && spec.restart_policy() == self.restart
78            && spec.effective_stop_policy(self.kind) == self.stop
79            && spec.should_autostart(self.kind) == self.autostart
80    }
81
82    /// Returns whether `process` has this fully resolved identity.
83    pub fn matches_process(&self, process: &Process) -> bool {
84        *process.kind() == self.kind
85            && process.name() == &self.name
86            && process.command() == &self.command
87            && process.working_dir() == &self.working_dir
88            && process.description() == &self.description
89            && *process.restart() == self.restart
90            && process.effective_stop_policy() == self.stop
91            && *process.autostart() == self.autostart
92    }
93
94    /// Edits autostart on the requested occurrence and reports whether it existed.
95    pub fn with_autostart(
96        &self,
97        config: WorkspaceConfig,
98        occurrence: usize,
99        autostart: Option<bool>,
100    ) -> (WorkspaceConfig, bool) {
101        let mut seen = 0;
102        let mut edited = false;
103        let mut apply = |specs: &[ProcessSpec]| {
104            specs
105                .iter()
106                .map(|spec| {
107                    if self.matches(spec) {
108                        let hit = seen == occurrence;
109                        seen += 1;
110                        if hit {
111                            edited = true;
112                            return spec.clone().with_autostart(autostart);
113                        }
114                    }
115                    spec.clone()
116                })
117                .collect()
118        };
119        let config = match self.kind {
120            ProcessKind::Agent => {
121                let specs = apply(config.agents());
122                config.with_agents(specs)
123            },
124            ProcessKind::Terminal => {
125                let specs = apply(config.terminals());
126                config.with_terminals(specs)
127            },
128            ProcessKind::Command => {
129                let specs = apply(config.commands());
130                config.with_commands(specs)
131            },
132        };
133        (config, edited)
134    }
135}
136
137impl Reconciliation {
138    /// Reconciles `workspace` with `config`, consulting `is_live` before
139    /// removing a process whose configuration entry disappeared.
140    pub fn apply(
141        workspace: &Workspace,
142        config: &WorkspaceConfig,
143        is_live: impl Fn(PaneId) -> bool,
144    ) -> Self {
145        let sections = [
146            (ProcessKind::Agent, config.agents()),
147            (ProcessKind::Terminal, config.terminals()),
148            (ProcessKind::Command, config.commands()),
149        ];
150        let mut config_counts = HashMap::new();
151        for (kind, specs) in sections {
152            for spec in specs {
153                *config_counts
154                    .entry(ProcessSpecIdentity::of_spec(kind, spec))
155                    .or_insert(0_usize) += 1;
156            }
157        }
158
159        let mut kept = Vec::new();
160        let mut tracked = Vec::new();
161        let mut retiring = Vec::new();
162        let mut removed = Vec::new();
163        let mut next_id = 0;
164        for process in workspace.processes() {
165            next_id = next_id.max((*process.id()).into_inner() + 1);
166            if *process.origin() == ProcessOrigin::Session {
167                kept.push(process.clone());
168                continue;
169            }
170            let identity = ProcessSpecIdentity::of_process(process);
171            let matched = config_counts
172                .get_mut(&identity)
173                .filter(|count| **count > 0)
174                .map(|count| *count -= 1)
175                .is_some();
176            let pane = *process.id();
177            if matched {
178                tracked.push(pane);
179                kept.push(process.clone());
180            } else if is_live(pane) {
181                retiring.push(pane);
182                kept.push(process.clone());
183            } else {
184                removed.push(pane);
185            }
186        }
187
188        for (kind, specs) in sections {
189            for spec in specs {
190                if let Some(count) = config_counts
191                    .get_mut(&ProcessSpecIdentity::of_spec(kind, spec))
192                    .filter(|count| **count > 0)
193                {
194                    *count -= 1;
195                    kept.push(spec.to_process(PaneId::new(next_id), kind));
196                    next_id += 1;
197                }
198            }
199        }
200
201        kept.sort_by_key(|process| process.kind().section_index());
202        let selected = workspace
203            .selected_process()
204            .map(|process| *process.id())
205            .and_then(|pane| kept.iter().position(|process| *process.id() == pane))
206            .unwrap_or(0);
207        Self::builder()
208            .workspace(
209                Workspace::builder()
210                    .processes(kept)
211                    .selected_index(selected)
212                    .build(),
213            )
214            .tracked(tracked)
215            .retiring(retiring)
216            .removed(removed)
217            .build()
218    }
219
220    /// Consumes the result and returns the reconciled workspace.
221    pub fn into_workspace(self) -> Workspace {
222        self.workspace
223    }
224}
225
226/// Full resolved identity of one configured process occurrence.
227#[derive(Hash, PartialEq, Eq)]
228struct ProcessSpecIdentity {
229    kind: ProcessKind,
230    name: ProcessName,
231    command: Option<CommandLine>,
232    working_dir: Option<std::path::PathBuf>,
233    description: Option<Description>,
234    restart: RestartPolicy,
235    stop: Option<StopPolicy>,
236    autostart: bool,
237}
238
239impl ProcessSpecIdentity {
240    /// Builds the resolved identity of one configuration entry.
241    fn of_spec(kind: ProcessKind, spec: &ProcessSpec) -> Self {
242        Self {
243            kind,
244            name: spec.name().clone(),
245            command: spec.command().clone(),
246            working_dir: spec.working_dir().clone(),
247            description: spec.description().clone(),
248            restart: spec.restart_policy(),
249            stop: spec.effective_stop_policy(kind),
250            autostart: spec.should_autostart(kind),
251        }
252    }
253
254    /// Builds the resolved identity of one live configured process.
255    fn of_process(process: &Process) -> Self {
256        Self {
257            kind: *process.kind(),
258            name: process.name().clone(),
259            command: process.command().clone(),
260            working_dir: process.working_dir().clone(),
261            description: process.description().clone(),
262            restart: *process.restart(),
263            stop: process.effective_stop_policy(),
264            autostart: *process.autostart(),
265        }
266    }
267}