Skip to main content

ipp_printer_app/
attributes.rs

1//! Build `Get-Printer-Attributes` / `Validate-Job` IPP responses.
2
3use std::collections::BTreeSet;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use ipp::attribute::{IppAttribute, IppAttributes};
7use ipp::model::DelimiterTag;
8use ipp::prelude::*;
9use ipp::request::IppRequestResponse;
10use ipp::value::IppValue;
11
12use crate::printer::{IppPrinterState, PrinterRecord};
13
14fn kw(s: &str) -> IppValue {
15    IppValue::Keyword(s.try_into().expect("keyword"))
16}
17
18fn mime(s: &str) -> IppValue {
19    IppValue::MimeMediaType(s.try_into().expect("mime"))
20}
21
22fn uri(s: &str) -> IppValue {
23    IppValue::Uri(s.try_into().expect("uri"))
24}
25
26fn charset(s: &str) -> IppValue {
27    IppValue::Charset(s.try_into().expect("charset"))
28}
29
30fn lang(s: &str) -> IppValue {
31    IppValue::NaturalLanguage(s.try_into().expect("language"))
32}
33
34fn attr(name: &str, value: IppValue) -> IppAttribute {
35    IppAttribute::new(name.try_into().expect("attr name"), value)
36}
37
38fn add(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, value: IppValue) {
39    attrs.add(tag, attr(name, value));
40}
41
42fn add_array_keyword(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, items: &[&str]) {
43    let values: Vec<IppValue> = items.iter().map(|s| kw(s)).collect();
44    add(attrs, tag, name, IppValue::Array(values));
45}
46
47fn text(s: &str) -> IppValue {
48    IppValue::TextWithoutLanguage(s.try_into().expect("text"))
49}
50
51fn add_array_enum(attrs: &mut IppAttributes, tag: DelimiterTag, name: &str, codes: &[i32]) {
52    let values: Vec<IppValue> = codes.iter().map(|c| IppValue::Enum(*c)).collect();
53    add(attrs, tag, name, IppValue::Array(values));
54}
55
56/// Break a Unix timestamp into a civil UTC `dateTime` value (Hinnant's
57/// `civil_from_days`). IPP `dateTime` (RFC 2579 / RFC 8011 §5.1.15).
58fn datetime_utc(unix_secs: i64) -> IppValue {
59    let days = unix_secs.div_euclid(86_400);
60    let rem = unix_secs.rem_euclid(86_400);
61    let (hour, minutes, seconds) = (rem / 3600, (rem % 3600) / 60, rem % 60);
62
63    let z = days + 719_468;
64    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
65    let doe = z - era * 146_097;
66    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
67    let year = yoe + era * 400;
68    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
69    let mp = (5 * doy + 2) / 153;
70    let day = doy - (153 * mp + 2) / 5 + 1;
71    let month = if mp < 10 { mp + 3 } else { mp - 9 };
72    let year = if month <= 2 { year + 1 } else { year };
73
74    IppValue::DateTime {
75        year: year as u16,
76        month: month as u8,
77        day: day as u8,
78        hour: hour as u8,
79        minutes: minutes as u8,
80        seconds: seconds as u8,
81        deci_seconds: 0,
82        utc_dir: '+',
83        utc_hours: 0,
84        utc_mins: 0,
85    }
86}
87
88fn now_unix() -> i64 {
89    SystemTime::now()
90        .duration_since(UNIX_EPOCH)
91        .map(|d| d.as_secs() as i64)
92        .unwrap_or(0)
93}
94
95/// Advertise localhost when the server bound to an unspecified address.
96fn advertise_host(host: &str) -> &str {
97    if host == "0.0.0.0" || host == "::" || host.is_empty() {
98        "localhost"
99    } else {
100        host
101    }
102}
103
104/// Build the advertised printer URI for a record.
105fn printer_uri(record: &PrinterRecord, host: &str, port: u16) -> String {
106    format!(
107        "ipp://{}:{}/ipp/print/{}",
108        advertise_host(host),
109        port,
110        record.config.name
111    )
112}
113
114/// Build a successful Get-Printer-Attributes response.
115///
116/// `requested` is the client's `requested-attributes` set. `None` (or a set
117/// containing the magic value `all`) returns the full attribute group; a
118/// concrete set filters the response down to the named attributes (RFC 8011
119/// §4.2.5).
120pub fn get_printer_attributes(
121    version: IppVersion,
122    request_id: u32,
123    record: &PrinterRecord,
124    host: &str,
125    port: u16,
126    requested: Option<&BTreeSet<String>>,
127) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
128    let mut resp =
129        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
130    let attrs = resp.attributes_mut();
131    let cfg = &record.config;
132    let printer_uri_str = printer_uri(record, host, port);
133    let more_info = format!(
134        "http://{}:{}/",
135        advertise_host(host),
136        port
137    );
138
139    let p = DelimiterTag::PrinterAttributes;
140    add(attrs, p, "printer-uri-supported", uri(&printer_uri_str));
141    add(attrs, p, "uri-authentication-supported", kw("none"));
142    add(attrs, p, "uri-security-supported", kw("none"));
143    add(
144        attrs,
145        p,
146        "printer-name",
147        IppValue::NameWithoutLanguage(cfg.name.as_str().try_into().unwrap()),
148    );
149    add(
150        attrs,
151        p,
152        "printer-location",
153        IppValue::TextWithoutLanguage("".try_into().unwrap()),
154    );
155    add(
156        attrs,
157        p,
158        "printer-info",
159        IppValue::TextWithoutLanguage(cfg.make_and_model.as_str().try_into().unwrap()),
160    );
161    add(
162        attrs,
163        p,
164        "printer-make-and-model",
165        IppValue::TextWithoutLanguage(cfg.make_and_model.as_str().try_into().unwrap()),
166    );
167    add(attrs, p, "printer-more-info", uri(&more_info));
168    add(
169        attrs,
170        p,
171        "printer-uuid",
172        uri(&format!("urn:uuid:{}", record.uuid)),
173    );
174    // RFC 8011 §5.4.29: seconds since the printer started; must be > 0 (the
175    // uptime clock starts lazily, so floor it at 1 for requests in the first
176    // second).
177    add(
178        attrs,
179        p,
180        "printer-up-time",
181        IppValue::Integer(uptime_secs().max(1) as i32),
182    );
183
184    add(attrs, p, "printer-state", IppValue::Enum(record.state as i32));
185    let reason_kws: Vec<&str> = record.reasons.ipp_keywords();
186    add_array_keyword(attrs, p, "printer-state-reasons", &reason_kws);
187    add(attrs, p, "printer-is-accepting-jobs", IppValue::Boolean(true));
188    add(attrs, p, "queued-job-count", IppValue::Integer(0));
189
190    add_array_keyword(attrs, p, "ipp-versions-supported", &["1.1", "2.0", "2.1"]);
191    add_array_keyword(attrs, p, "ipp-features-supported", &["ipp-everywhere"]);
192    add(attrs, p, "pdl-override-supported", kw("attempted"));
193    if !cfg.device_id.is_empty() {
194        add(
195            attrs,
196            p,
197            "printer-device-id",
198            IppValue::TextWithoutLanguage(cfg.device_id.as_str().try_into().unwrap()),
199        );
200    }
201    // RFC 8011 §5.4.15: `1setOf enum` carrying the operation *codes*, not
202    // keyword names. Order follows the numeric code.
203    add_array_enum(
204        attrs,
205        p,
206        "operations-supported",
207        &[
208            0x0002, // Print-Job
209            0x0004, // Validate-Job
210            0x0005, // Create-Job
211            0x0006, // Send-Document
212            0x0008, // Cancel-Job
213            0x0009, // Get-Job-Attributes
214            0x000a, // Get-Jobs
215            0x000b, // Get-Printer-Attributes
216            0x0039, // Cancel-My-Jobs
217            0x003b, // Close-Job
218            0x003c, // Identify-Printer
219        ],
220    );
221    add(
222        attrs,
223        p,
224        "charset-configured",
225        charset("utf-8"),
226    );
227    add(
228        attrs,
229        p,
230        "charset-supported",
231        IppValue::Array(vec![charset("utf-8")]),
232    );
233    add(attrs, p, "natural-language-configured", lang("en"));
234    add(
235        attrs,
236        p,
237        "natural-language-supported",
238        IppValue::Array(vec![lang("en")]),
239    );
240    add(
241        attrs,
242        p,
243        "generated-natural-language-supported",
244        IppValue::Array(vec![lang("en")]),
245    );
246    add_array_keyword(attrs, p, "compression-supported", &["none"]);
247
248    // PWG raster is the IPP Everywhere required format; the unified CUPS reader
249    // also handles legacy CUPS raster v1/v2 if a client picks that path. The
250    // consumer may extend this via `PrinterConfig::document_formats` (e.g. to
251    // advertise `image/jpeg` once its backend decodes it).
252    let format_values: Vec<IppValue> = if cfg.document_formats.is_empty() {
253        vec![
254            mime("image/pwg-raster"),
255            mime("application/vnd.cups-raster"),
256            mime("application/octet-stream"),
257        ]
258    } else {
259        cfg.document_formats.iter().map(|f| mime(f)).collect()
260    };
261    add(
262        attrs,
263        p,
264        "document-format-supported",
265        IppValue::Array(format_values),
266    );
267    add(
268        attrs,
269        p,
270        "document-format-default",
271        mime("image/pwg-raster"),
272    );
273    // PWG raster type for the everywhere driver.
274    add_array_keyword(
275        attrs,
276        p,
277        "pwg-raster-document-type-supported",
278        &["black_1"],
279    );
280    // PWG 5102.4 §6.2.1: `1setOf resolution`. CUPS 2.4.16+ (Ubuntu 26.04)
281    // requires this typing; 2.4.10 (Debian trixie) regrettably has a bug
282    // and looks it up as IPP_TAG_KEYWORD instead — the spec form wins.
283    add(
284        attrs,
285        p,
286        "pwg-raster-document-resolution-supported",
287        IppValue::Array(vec![IppValue::Resolution {
288            cross_feed: cfg.dpi,
289            feed: cfg.dpi,
290            units: 3,
291        }]),
292    );
293    add_array_keyword(
294        attrs,
295        p,
296        "urf-supported",
297        &["W8", "SRGB24", "CP1", "RS203"],
298    );
299
300    add(attrs, p, "color-supported", IppValue::Boolean(false));
301    add_array_keyword(attrs, p, "print-color-mode-supported", &["monochrome"]);
302    add(attrs, p, "print-color-mode-default", kw("monochrome"));
303    add_array_keyword(attrs, p, "sides-supported", &["one-sided"]);
304    add(attrs, p, "sides-default", kw("one-sided"));
305    add(attrs, p, "orientation-requested-default", IppValue::Enum(3));
306    // portrait / landscape / reverse-landscape / reverse-portrait (RFC 8011).
307    add_array_enum(attrs, p, "orientation-requested-supported", &[3, 4, 5, 6]);
308
309    // Identify-Printer actions (PWG 5100.14 §5.1). The framework dispatches
310    // the operation to `DeviceBackend::identify`; we advertise a display-type
311    // action which any backend can honour (a beep/LED maps to `sound`/`flash`).
312    add_array_keyword(attrs, p, "identify-actions-supported", &["display", "sound"]);
313    add_array_keyword(attrs, p, "identify-actions-default", &["display"]);
314
315    // IPP Everywhere required descriptors. We expose conservative defaults that
316    // satisfy CUPS' `-m everywhere` PPD generator without claiming features
317    // we don't implement (no real trays, no finishings).
318    add_array_keyword(attrs, p, "media-source-supported", &["main"]);
319    add_array_keyword(attrs, p, "media-type-supported", &["labels", "stationery"]);
320    add_array_keyword(attrs, p, "output-bin-supported", &["face-up"]);
321    add(attrs, p, "output-bin-default", kw("face-up"));
322    add_array_keyword(
323        attrs,
324        p,
325        "print-content-optimize-supported",
326        &["auto", "graphic", "photo", "text", "text-and-graphic"],
327    );
328    add(attrs, p, "print-content-optimize-default", kw("auto"));
329    // RFC 8011 §5.2.6: `1setOf enum`. `3` == `none`.
330    add_array_enum(attrs, p, "finishings-supported", &[3]);
331    add(attrs, p, "finishings-default", IppValue::Enum(3));
332    add(attrs, p, "job-creation-attributes-supported", IppValue::Array(vec![
333        kw("copies"),
334        kw("media"),
335        kw("media-col"),
336        kw("orientation-requested"),
337        kw("print-color-mode"),
338        kw("print-content-optimize"),
339        kw("print-quality"),
340        kw("printer-resolution"),
341        kw("sides"),
342    ]));
343
344    add(
345        attrs,
346        p,
347        "printer-resolution-default",
348        IppValue::Resolution {
349            cross_feed: cfg.dpi,
350            feed: cfg.dpi,
351            units: 3,
352        },
353    );
354    add(
355        attrs,
356        p,
357        "printer-resolution-supported",
358        IppValue::Array(vec![IppValue::Resolution {
359            cross_feed: cfg.dpi,
360            feed: cfg.dpi,
361            units: 3,
362        }]),
363    );
364
365    let media_kws: Vec<&str> = cfg.media_names.iter().map(|s| s.as_str()).collect();
366    if !media_kws.is_empty() {
367        add(attrs, p, "media-default", kw(media_kws[0]));
368        add_array_keyword(attrs, p, "media-supported", &media_kws);
369
370        // media-col-{default} — required by IPP Everywhere.
371        let default_size = cfg.media_sizes.first().copied().unwrap_or([4000, 3000]);
372        add(
373            attrs,
374            p,
375            "media-col-default",
376            media_col(media_kws[0], default_size),
377        );
378        let media_cols: Vec<IppValue> = media_kws
379            .iter()
380            .zip(cfg.media_sizes.iter().copied().chain(std::iter::repeat(default_size)))
381            .map(|(name, size)| media_col(name, size))
382            .collect();
383        // PWG 5100.13: `media-col-supported` is `1setOf keyword` naming the
384        // member attributes a client may set in a `media-col` collection — NOT
385        // the collections themselves (that's `media-col-database`, which CUPS'
386        // `lpadmin -m everywhere` PPD generator walks to enumerate sizes).
387        add_array_keyword(
388            attrs,
389            p,
390            "media-col-supported",
391            &[
392                "media-size",
393                "media-size-name",
394                "media-top-margin",
395                "media-bottom-margin",
396                "media-left-margin",
397                "media-right-margin",
398                "media-source",
399                "media-type",
400            ],
401        );
402        add(attrs, p, "media-col-database", IppValue::Array(media_cols.clone()));
403
404        // `media-size-supported` (PWG 5100.12 §6.3.x): `1setOf collection` of
405        // bare `media-size` (x/y only), distinct from `media-col-database`.
406        let media_sizes: Vec<IppValue> = media_kws
407            .iter()
408            .zip(cfg.media_sizes.iter().copied().chain(std::iter::repeat(default_size)))
409            .map(|(_, size)| media_size_col(size))
410            .collect();
411        add(attrs, p, "media-size-supported", IppValue::Array(media_sizes));
412
413        // media-ready / media-col-ready — the loaded media. The status poller
414        // fills `record.ready_media` with live roll data; absent that we fall
415        // back to the configured default so the (required) attributes exist.
416        let (ready_name, ready_size) = match &record.ready_media {
417            Some(rm) => (rm.name.as_str(), rm.size_hmm),
418            None => (media_kws[0], default_size),
419        };
420        add(attrs, p, "media-ready", kw(ready_name));
421        add(
422            attrs,
423            p,
424            "media-col-ready",
425            IppValue::Array(vec![media_col(ready_name, ready_size)]),
426        );
427    }
428
429    // Hard-margin support. A thermal label printer prints edge-to-edge: 0 on
430    // all sides (hundredths of a millimetre). Required by IPP Everywhere.
431    for margin in [
432        "media-top-margin-supported",
433        "media-bottom-margin-supported",
434        "media-left-margin-supported",
435        "media-right-margin-supported",
436    ] {
437        add(attrs, p, margin, IppValue::Integer(0));
438    }
439
440    add(
441        attrs,
442        p,
443        "copies-supported",
444        IppValue::RangeOfInteger { min: 1, max: 999 },
445    );
446    add(attrs, p, "copies-default", IppValue::Integer(1));
447    add(
448        attrs,
449        p,
450        "print-quality-supported",
451        IppValue::Array(vec![
452            IppValue::Enum(3),
453            IppValue::Enum(4),
454            IppValue::Enum(5),
455        ]),
456    );
457    add(attrs, p, "print-quality-default", IppValue::Enum(4));
458
459    // --- Job/limit descriptors (PWG 5100.14 §5.x, mostly static) ---
460    add(attrs, p, "multiple-document-jobs-supported", IppValue::Boolean(false));
461    add(attrs, p, "multiple-operation-time-out", IppValue::Integer(60));
462    add(attrs, p, "multiple-operation-time-out-action", kw("process-job"));
463    add(attrs, p, "job-ids-supported", IppValue::Boolean(true));
464    add(attrs, p, "preferred-attributes-supported", IppValue::Boolean(false));
465    add_array_keyword(attrs, p, "overrides-supported", &["document-number", "pages"]);
466    add_array_keyword(attrs, p, "printer-get-attributes-supported", &["document-format"]);
467    add_array_keyword(
468        attrs,
469        p,
470        "which-jobs-supported",
471        &[
472            "completed",
473            "not-completed",
474            "aborted",
475            "canceled",
476            "pending",
477            "processing",
478        ],
479    );
480
481    // --- Rendering descriptors ---
482    add(attrs, p, "print-rendering-intent-default", kw("auto"));
483    add_array_keyword(attrs, p, "print-rendering-intent-supported", &["auto"]);
484    // One-sided printer: the back side is rendered the same way as the front.
485    add(attrs, p, "pwg-raster-document-sheet-back", kw("normal"));
486
487    // --- Identity / admin descriptors ---
488    // Location is not known to the framework; out-of-band `unknown` is the
489    // honest value (a real `geo:` URI would be fabricated coordinates).
490    add(attrs, p, "printer-geo-location", IppValue::Other { tag: 0x12, data: Vec::<u8>::new().into() });
491    add(attrs, p, "printer-organization", text(""));
492    add(attrs, p, "printer-organizational-unit", text(""));
493    add(
494        attrs,
495        p,
496        "printer-icons",
497        IppValue::Array(vec![uri(&format!(
498            "http://{}:{}/icon.png",
499            advertise_host(host),
500            port
501        ))]),
502    );
503    add(attrs, p, "pages-per-minute", IppValue::Integer(20));
504
505    // --- Supply / consumable (PWG 5100.14). The device backend can overwrite
506    // these per-poll with the real labels-remaining gauge; the static fallback
507    // keeps the required attributes present. ---
508    // Live remaining-supply level from the poller, else assume full.
509    let supply_level = record.supply_percent.unwrap_or(100);
510    add(
511        attrs,
512        p,
513        "printer-supply",
514        IppValue::Array(vec![IppValue::OctetString(
515            format!(
516                "index=1;class=supplyThatIsConsumed;type=stoppingMaterial;\
517                 unit=percent;maxcapacity=100;level={supply_level};colorantname=unknown;"
518            )
519            .try_into()
520            .expect("supply"),
521        )]),
522    );
523    add(
524        attrs,
525        p,
526        "printer-supply-description",
527        IppValue::Array(vec![text("Label Stock")]),
528    );
529    add(
530        attrs,
531        p,
532        "printer-supply-info-uri",
533        uri(&format!("http://{}:{}/", advertise_host(host), port)),
534    );
535
536    // --- Change tracking (RFC 8011 §5.4.26-29) ---
537    let now = now_unix();
538    add(attrs, p, "printer-config-change-time", IppValue::Integer(uptime_secs() as i32));
539    add(attrs, p, "printer-config-change-date-time", datetime_utc(now));
540    add(attrs, p, "printer-state-change-time", IppValue::Integer(uptime_secs() as i32));
541    add(attrs, p, "printer-state-change-date-time", datetime_utc(now));
542
543    filter_requested(&mut resp, requested);
544    Ok(resp)
545}
546
547/// Apply `requested-attributes` filtering to a freshly-built
548/// Get-Printer-Attributes response. `None` or a set containing the magic
549/// value `all` is a no-op (return everything). Otherwise the printer-attribute
550/// group is reduced to the explicitly-named attributes (RFC 8011 §4.2.5). The
551/// always-present operation attributes (charset / language) are preserved.
552fn filter_requested(resp: &mut IppRequestResponse, requested: Option<&BTreeSet<String>>) {
553    let Some(set) = requested else { return };
554    if set.is_empty() || set.contains("all") {
555        return;
556    }
557    for group in resp.attributes_mut().groups_mut() {
558        if group.tag() != DelimiterTag::PrinterAttributes {
559            continue;
560        }
561        group
562            .attributes_mut()
563            .retain(|name, _| set.contains(name.as_str()));
564    }
565}
566
567/// Validate-Job: same capability surface as Get-Printer-Attributes (success).
568pub fn validate_job(
569    version: IppVersion,
570    request_id: u32,
571    record: &PrinterRecord,
572    host: &str,
573    port: u16,
574) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
575    get_printer_attributes(version, request_id, record, host, port, None)
576}
577
578/// Build the `Print-Job` accepted response for a freshly-allocated job.
579pub fn print_job_accepted(
580    version: IppVersion,
581    request_id: u32,
582    job: &crate::job::JobRecord,
583    printer_uri_str: &str,
584) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
585    let mut resp =
586        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
587    let job_uri_str = format!("{printer_uri_str}/job/{}", job.id);
588    let j = DelimiterTag::JobAttributes;
589    add(resp.attributes_mut(), j, "job-uri", uri(&job_uri_str));
590    add(
591        resp.attributes_mut(),
592        j,
593        "job-id",
594        IppValue::Integer(job.id as i32),
595    );
596    add(
597        resp.attributes_mut(),
598        j,
599        "job-state",
600        IppValue::Enum(job.state as i32),
601    );
602    add_array_keyword(
603        resp.attributes_mut(),
604        j,
605        "job-state-reasons",
606        &job_state_reason_keywords(job),
607    );
608    Ok(resp)
609}
610
611/// Build a `Get-Job-Attributes` response for a single job. `requested` filters
612/// the returned attributes (`None` = all, the Get-Job-Attributes default).
613pub fn build_job_attrs_response(
614    version: IppVersion,
615    request_id: u32,
616    job: &crate::job::JobRecord,
617    printer_uri_str: &str,
618    requested: Option<&BTreeSet<String>>,
619) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
620    let mut resp =
621        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
622    for a in job_attrs_for_group(job, printer_uri_str, requested) {
623        resp.attributes_mut().add(DelimiterTag::JobAttributes, a);
624    }
625    Ok(resp)
626}
627
628/// Build a `Get-Jobs` response listing one job per group. `requested` filters
629/// the per-job attributes; the Get-Jobs default (`None`) is `job-uri` +
630/// `job-id` only (RFC 8011 §3.2.6.1), supplied by the caller.
631pub fn build_get_jobs_response(
632    version: IppVersion,
633    request_id: u32,
634    jobs: &[crate::job::JobRecord],
635    printer_uri_str: &str,
636    requested: Option<&BTreeSet<String>>,
637) -> Result<IppRequestResponse, ipp::parser::IppParseError> {
638    let mut resp =
639        IppRequestResponse::new_response(version, StatusCode::SuccessfulOk, request_id)?;
640    // Each job goes in its own JobAttributes group. The `ipp` crate's `add`
641    // merges all attrs with the same DelimiterTag into one group, which is
642    // wrong for multi-job responses — we push raw groups instead.
643    for job in jobs {
644        let mut group = ipp::attribute::IppAttributeGroup::new(DelimiterTag::JobAttributes);
645        for a in job_attrs_for_group(job, printer_uri_str, requested) {
646            group
647                .attributes_mut()
648                .insert(a.name().to_owned(), a);
649        }
650        resp.attributes_mut().groups_mut().push(group);
651    }
652    Ok(resp)
653}
654
655fn job_attrs_for_group(
656    job: &crate::job::JobRecord,
657    printer_uri_str: &str,
658    requested: Option<&BTreeSet<String>>,
659) -> Vec<IppAttribute> {
660    let job_uri_str = format!("{printer_uri_str}/job/{}", job.id);
661    let mut out = vec![
662        attr("job-uri", uri(&job_uri_str)),
663        attr("job-id", IppValue::Integer(job.id as i32)),
664        attr("job-printer-uri", uri(printer_uri_str)),
665        attr(
666            "job-name",
667            IppValue::NameWithoutLanguage(
668                format!("job-{}", job.id).as_str().try_into().unwrap(),
669            ),
670        ),
671        attr("job-state", IppValue::Enum(job.state as i32)),
672        attr(
673            "job-originating-user-name",
674            IppValue::NameWithoutLanguage(job.owner.as_str().try_into().unwrap_or_else(|_| {
675                "anonymous".try_into().expect("anonymous")
676            })),
677        ),
678        attr("time-at-creation", IppValue::Integer(job.created_secs())),
679    ];
680    let reason_kws = job_state_reason_keywords(job);
681    out.push(attr(
682        "job-state-reasons",
683        IppValue::Array(reason_kws.iter().map(|s| kw(s)).collect()),
684    ));
685    if !job.message.is_empty() {
686        out.push(attr(
687            "job-state-message",
688            IppValue::TextWithoutLanguage(job.message.as_str().try_into().unwrap()),
689        ));
690    }
691    // We don't separately track when processing began; the mock pipeline
692    // starts work as soon as the job is accepted, so creation time is a faithful
693    // stand-in. Required by RFC 8011 (no-value|integer).
694    out.push(attr("time-at-processing", IppValue::Integer(job.created_secs())));
695    out.push(attr("job-printer-up-time", IppValue::Integer(uptime_secs() as i32)));
696    if let Some(s) = job.completed_secs() {
697        out.push(attr("time-at-completed", IppValue::Integer(s)));
698    }
699    if let Some(set) = requested {
700        out.retain(|a| set.contains(a.name().as_str()));
701    }
702    out
703}
704
705fn job_state_reason_keywords(job: &crate::job::JobRecord) -> Vec<&'static str> {
706    use crate::flags::PrinterReason;
707    use crate::job::JobState;
708    let mut out = Vec::new();
709    if job.reasons.contains(PrinterReason::MEDIA_EMPTY) {
710        out.push("job-completed-with-errors");
711    }
712    if job.reasons.contains(PrinterReason::MEDIA_JAM) {
713        out.push("aborted-by-system");
714    }
715    if job.reasons.contains(PrinterReason::OFFLINE) {
716        out.push("connection-error");
717    }
718    match job.state {
719        JobState::Canceled => out.push("job-canceled-by-user"),
720        JobState::Completed => out.push("job-completed-successfully"),
721        JobState::Aborted if out.is_empty() => out.push("aborted-by-system"),
722        _ => {}
723    }
724    if out.is_empty() {
725        out.push("none");
726    }
727    out
728}
729
730/// Transition the printer into `IppPrinterState::Processing`.
731pub fn set_printer_processing(record: &mut PrinterRecord) {
732    record.state = IppPrinterState::Processing;
733}
734
735/// Transition the printer back to `IppPrinterState::Idle`.
736pub fn set_printer_idle(record: &mut PrinterRecord) {
737    record.state = IppPrinterState::Idle;
738}
739
740/// Build a `media-col` collection with `media-size` (x/y in hundredths of mm)
741/// and `media-size-name`. CUPS expects PWG dimensions in hundredths of mm.
742fn media_col(name: &str, size_hmm: [i32; 2]) -> IppValue {
743    use std::collections::BTreeMap;
744    let mut size = BTreeMap::new();
745    size.insert(
746        "x-dimension".try_into().unwrap(),
747        IppValue::Integer(size_hmm[0]),
748    );
749    size.insert(
750        "y-dimension".try_into().unwrap(),
751        IppValue::Integer(size_hmm[1]),
752    );
753    let mut col = BTreeMap::new();
754    col.insert(
755        "media-size".try_into().unwrap(),
756        IppValue::Collection(size),
757    );
758    col.insert(
759        "media-size-name".try_into().unwrap(),
760        kw(name),
761    );
762    IppValue::Collection(col)
763}
764
765/// Build a bare `media-size` collection (x/y dimensions only) for
766/// `media-size-supported`.
767fn media_size_col(size_hmm: [i32; 2]) -> IppValue {
768    use std::collections::BTreeMap;
769    let mut size = BTreeMap::new();
770    size.insert(
771        "x-dimension".try_into().unwrap(),
772        IppValue::Integer(size_hmm[0]),
773    );
774    size.insert(
775        "y-dimension".try_into().unwrap(),
776        IppValue::Integer(size_hmm[1]),
777    );
778    IppValue::Collection(size)
779}
780
781fn uptime_secs() -> u64 {
782    use std::sync::OnceLock;
783    use std::time::Instant;
784    static START: OnceLock<Instant> = OnceLock::new();
785    START.get_or_init(Instant::now).elapsed().as_secs()
786}