Skip to main content

foundry_tui_app/controller/
jobs.rs

1use chrono::Local;
2use foundry_tui_foundry::{StreamKind, ToolEvent, ToolKind, ToolRequest};
3use tokio::sync::mpsc::UnboundedSender;
4
5use crate::{
6    model::{AnvilInstanceStatus, JobRecord, JobStatus, LogLine, LogStream},
7    parsing::contains_placeholders,
8    project_inventory::scan_project_inventory,
9};
10
11use super::AppController;
12
13impl AppController {
14    pub fn handle_tool_event(&mut self, event: ToolEvent) {
15        match event {
16            ToolEvent::Started {
17                job_id,
18                commandline,
19            } => {
20                let entry = LogLine {
21                    ts: Local::now(),
22                    job_id: Some(job_id),
23                    stream: LogStream::System,
24                    message: format!("starting `{}`", commandline),
25                };
26                self.push_log(entry.clone());
27                self.push_anvil_log(job_id, entry);
28                self.set_anvil_instance_status(job_id, AnvilInstanceStatus::Running);
29
30                if let Some(job) = self.model.jobs.get_mut(&job_id) {
31                    job.commandline = commandline;
32                }
33            }
34            ToolEvent::Output {
35                job_id,
36                stream,
37                line,
38            } => {
39                let stream = match stream {
40                    StreamKind::Stdout => LogStream::Stdout,
41                    StreamKind::Stderr => LogStream::Stderr,
42                };
43
44                let entry = LogLine {
45                    ts: Local::now(),
46                    job_id: Some(job_id),
47                    stream,
48                    message: line,
49                };
50                self.push_log(entry.clone());
51                self.push_anvil_log(job_id, entry);
52            }
53            ToolEvent::Finished { job_id, result } => {
54                let status = if result.cancelled {
55                    JobStatus::Cancelled
56                } else if result.status_code == Some(0) {
57                    JobStatus::Success
58                } else {
59                    JobStatus::Failed
60                };
61
62                if let Some(job) = self.model.jobs.get_mut(&job_id) {
63                    job.status = status;
64                    job.finished_at = Some(Local::now());
65                    job.duration_ms = Some(result.duration_ms);
66                    job.status_code = result.status_code;
67                }
68
69                if let Some(job) = self.model.jobs.get(&job_id).cloned() {
70                    self.model.history.insert(0, job);
71                    self.model.history.truncate(self.config.jobs.keep_history);
72                }
73
74                self.job_manager.mark_finished(job_id);
75                let anvil_status = match status {
76                    JobStatus::Failed => AnvilInstanceStatus::Failed,
77                    JobStatus::Running => AnvilInstanceStatus::Running,
78                    JobStatus::Success | JobStatus::Cancelled => AnvilInstanceStatus::Stopped,
79                };
80                self.set_anvil_instance_status(job_id, anvil_status);
81
82                self.model.notification = Some(format!(
83                    "job #{job_id} {} in {}ms",
84                    status.label(),
85                    result.duration_ms
86                ));
87
88                let entry = LogLine {
89                    ts: Local::now(),
90                    job_id: Some(job_id),
91                    stream: LogStream::System,
92                    message: format!(
93                        "completed status={} code={:?} stdout={} stderr={}",
94                        status.label(),
95                        result.status_code,
96                        result.stdout_lines.len(),
97                        result.stderr_lines.len()
98                    ),
99                };
100                self.push_log(entry.clone());
101                self.push_anvil_log(job_id, entry);
102            }
103            ToolEvent::Failed { job_id, error } => {
104                if let Some(job) = self.model.jobs.get_mut(&job_id) {
105                    job.status = JobStatus::Failed;
106                    job.finished_at = Some(Local::now());
107                }
108
109                self.job_manager.mark_finished(job_id);
110                self.set_anvil_instance_status(job_id, AnvilInstanceStatus::Failed);
111
112                self.model.notification = Some(format!("job #{job_id} failed to start"));
113                let entry = LogLine {
114                    ts: Local::now(),
115                    job_id: Some(job_id),
116                    stream: LogStream::System,
117                    message: format!("launch error: {error}"),
118                };
119                self.push_log(entry.clone());
120                self.push_anvil_log(job_id, entry);
121            }
122        }
123    }
124
125    pub(crate) fn start_tool_job(
126        &mut self,
127        name: &str,
128        tool: ToolKind,
129        args: Vec<String>,
130        tool_events: &UnboundedSender<ToolEvent>,
131    ) -> Option<u64> {
132        if contains_placeholders(&args) {
133            self.model.notification = Some(format!(
134                "{name} has placeholders. Edit config at {}",
135                self.model.config_path.display()
136            ));
137            return None;
138        }
139
140        let mut request = ToolRequest::new(tool, args, self.model.project_root.clone());
141
142        if matches!(tool, ToolKind::Forge) {
143            request.profile = Some(self.config.foundry.profile.clone());
144        }
145
146        if matches!(tool, ToolKind::Cast | ToolKind::Forge) {
147            request.rpc_target = self.default_rpc_target();
148        }
149
150        match self.start_tool_request_job(name, request, tool_events) {
151            Ok(job_id) => {
152                self.model.notification = Some(format!("queued {name} as job #{job_id}"));
153                Some(job_id)
154            }
155            Err(_) => None,
156        }
157    }
158
159    pub(crate) fn start_tool_request_job(
160        &mut self,
161        name: &str,
162        request: ToolRequest,
163        tool_events: &UnboundedSender<ToolEvent>,
164    ) -> std::result::Result<u64, String> {
165        let commandline = request.display_commandline();
166        let started_at = Local::now();
167
168        let job_id = match self.job_manager.spawn(request, tool_events.clone()) {
169            Ok(job_id) => job_id,
170            Err(error) => {
171                self.model.notification = Some(error.clone());
172                self.push_log(LogLine {
173                    ts: Local::now(),
174                    job_id: None,
175                    stream: LogStream::System,
176                    message: format!("unable to queue `{name}`: {error}"),
177                });
178                return Err(error);
179            }
180        };
181
182        self.model.jobs.insert(
183            job_id,
184            JobRecord {
185                id: job_id,
186                name: name.to_string(),
187                commandline,
188                status: JobStatus::Running,
189                started_at,
190                finished_at: None,
191                duration_ms: None,
192                status_code: None,
193            },
194        );
195
196        Ok(job_id)
197    }
198
199    pub(crate) fn default_rpc_target(&self) -> Option<String> {
200        self.active_rpc_target().map(|(_, url)| url)
201    }
202
203    pub(crate) fn active_rpc_target(&self) -> Option<(String, String)> {
204        if let Some(preset) = &self.config.foundry.default_rpc_preset {
205            if let Some(url) = self.config.rpc_presets.get(preset) {
206                return Some((preset.clone(), url.clone()));
207            }
208        }
209
210        self.config
211            .rpc_presets
212            .iter()
213            .next()
214            .map(|(name, url)| (name.clone(), url.clone()))
215    }
216
217    pub(crate) fn refresh_project_inventory(&mut self) {
218        let inventory = scan_project_inventory(&self.model.project_root);
219        self.model.project_sol_files = inventory.sol_files;
220        self.model.project_has_foundry_toml = inventory.has_foundry_toml;
221        self.model.project_has_remappings = inventory.has_remappings;
222        self.model.project_indexed_at = Local::now();
223    }
224
225    pub(crate) fn push_log(&mut self, entry: LogLine) {
226        self.model.logs.push(entry);
227        if self.model.logs.len() > self.config.jobs.max_log_lines {
228            let overflow = self.model.logs.len() - self.config.jobs.max_log_lines;
229            self.model.logs.drain(0..overflow);
230        }
231    }
232
233    pub(crate) fn push_anvil_log(&mut self, job_id: u64, entry: LogLine) {
234        let Some(instance) = self
235            .model
236            .anvil_instances
237            .iter_mut()
238            .find(|instance| instance.job_id == job_id)
239        else {
240            return;
241        };
242
243        instance.logs.push(entry);
244        if instance.logs.len() > self.config.jobs.max_log_lines {
245            let overflow = instance.logs.len() - self.config.jobs.max_log_lines;
246            instance.logs.drain(0..overflow);
247        }
248    }
249
250    pub(crate) fn set_anvil_instance_status(&mut self, job_id: u64, status: AnvilInstanceStatus) {
251        if let Some(instance) = self
252            .model
253            .anvil_instances
254            .iter_mut()
255            .find(|instance| instance.job_id == job_id)
256        {
257            instance.status = status;
258        }
259    }
260}