1use std::collections::VecDeque;
30use std::fmt::Write as _;
31use std::io::{self, Write};
32use std::sync::Arc;
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use parking_lot::Mutex;
36
37use serde::Serialize;
38
39use crate::cluster::peer::PeerState;
40use crate::cluster::pool::ServerPool;
41use crate::conf::ConfPool;
42use crate::embed::ServerHandle;
43use crate::hashkit::DynToken;
44use crate::stats::PoolField;
45
46pub const MAX_RECENT_EVENTS: usize = 50;
54
55#[derive(Clone, Debug, Default, Eq, PartialEq)]
69pub struct Section {
70 name: String,
71 pairs: Vec<(String, String)>,
72}
73
74impl Section {
75 #[must_use]
87 pub fn with_pairs(name: &str, pairs: &[(&str, &str)]) -> Self {
88 Self {
89 name: name.to_string(),
90 pairs: pairs
91 .iter()
92 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
93 .collect(),
94 }
95 }
96
97 #[must_use]
99 pub fn name(&self) -> &str {
100 &self.name
101 }
102
103 #[must_use]
105 pub fn pairs(&self) -> &[(String, String)] {
106 &self.pairs
107 }
108
109 pub fn push<K: Into<String>, V: Into<String>>(&mut self, key: K, value: V) {
111 self.pairs.push((key.into(), value.into()));
112 }
113}
114
115#[derive(Clone, Debug, Default, Eq, PartialEq)]
118pub struct RowSection {
119 name: String,
120 rows: Vec<String>,
121}
122
123impl RowSection {
124 #[must_use]
126 pub fn new(name: &str) -> Self {
127 Self {
128 name: name.to_string(),
129 rows: Vec::new(),
130 }
131 }
132
133 #[must_use]
135 pub fn name(&self) -> &str {
136 &self.name
137 }
138
139 #[must_use]
141 pub fn rows(&self) -> &[String] {
142 &self.rows
143 }
144
145 pub fn push<S: Into<String>>(&mut self, row: S) {
147 self.rows.push(row.into());
148 }
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct RecentEvent {
167 pub ts_secs: u64,
169 pub kind: String,
173 pub detail: String,
175}
176
177impl RecentEvent {
178 #[must_use]
180 pub fn new<K: Into<String>, D: Into<String>>(ts_secs: u64, kind: K, detail: D) -> Self {
181 Self {
182 ts_secs,
183 kind: kind.into(),
184 detail: detail.into(),
185 }
186 }
187}
188
189#[derive(Clone, Debug, Default)]
206pub struct RecentEvents {
207 inner: Arc<Mutex<VecDeque<RecentEvent>>>,
208}
209
210impl RecentEvents {
211 #[must_use]
213 pub fn new() -> Self {
214 Self {
215 inner: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_RECENT_EVENTS))),
216 }
217 }
218
219 pub fn push(&self, event: RecentEvent) {
222 let mut guard = self.inner.lock();
223 if guard.len() == MAX_RECENT_EVENTS {
224 guard.pop_front();
225 }
226 guard.push_back(event);
227 }
228
229 #[must_use]
231 pub fn snapshot(&self) -> Vec<RecentEvent> {
232 self.inner.lock().iter().cloned().collect()
233 }
234
235 #[must_use]
237 pub fn len(&self) -> usize {
238 self.inner.lock().len()
239 }
240
241 #[must_use]
243 pub fn is_empty(&self) -> bool {
244 self.inner.lock().is_empty()
245 }
246}
247
248#[derive(Clone, Debug, Default, Eq, PartialEq)]
265pub struct ClusterInfoSnapshot {
266 pub build: Section,
268 pub config: Section,
270 pub ring: Section,
272 pub peers: RowSection,
274 pub queues: Section,
276 pub gossip: Section,
278 pub recent_events: RowSection,
280 pub memory: Section,
282 pub fds: Section,
284}
285
286impl ClusterInfoSnapshot {
287 #[must_use]
302 pub fn synthetic() -> Self {
303 let mut snap = Self {
304 build: Section::with_pairs(
305 "build",
306 &[
307 ("version", "0.0.1"),
308 ("git_sha", "unknown"),
309 ("build_profile", "debug"),
310 ],
311 ),
312 config: Section::with_pairs(
313 "config",
314 &[
315 ("data_store", "redis"),
316 ("listen", "0.0.0.0:8102"),
317 ("dyn_listen", "0.0.0.0:8101"),
318 ("rack", "rack-1"),
319 ("dc", "dc-1"),
320 ],
321 ),
322 ring: Section::with_pairs(
323 "ring",
324 &[
325 ("distribution", "vnode"),
326 ("vnodes", "1"),
327 ("tokens_per_node", "1"),
328 ],
329 ),
330 peers: RowSection::new("peers"),
331 queues: Section::with_pairs(
332 "queues",
333 &[
334 ("dispatcher_inflight", "0"),
335 ("backend_supervisor_pending", "0"),
336 ("hint_store_size", "0"),
337 ],
338 ),
339 gossip: Section::with_pairs(
340 "gossip",
341 &[
342 ("churn_total", "0"),
343 ("phi_alarms_total", "0"),
344 ("heartbeats_sent", "0"),
345 ("heartbeats_received", "0"),
346 ],
347 ),
348 recent_events: RowSection::new("recent_events"),
349 memory: Section::with_pairs("memory", &[("rss_bytes", "unavailable")]),
350 fds: Section::with_pairs("fds", &[("total", "unavailable")]),
351 };
352 snap.peers.push(
353 "peer_id=local dc=dc-1 rack=rack-1 status=Normal phi=0.00 last_seen_ms=0 tokens=1",
354 );
355 snap.recent_events
356 .push("1970-01-01T00:00:00Z restart local-bootstrap");
357 snap
358 }
359}
360
361pub fn format_text<W: Write>(snap: &ClusterInfoSnapshot, w: &mut W) -> io::Result<()> {
396 write_pair_section(w, &snap.build)?;
397 write_pair_section(w, &snap.config)?;
398 write_pair_section(w, &snap.ring)?;
399 write_row_section(w, &snap.peers)?;
400 write_pair_section(w, &snap.queues)?;
401 write_pair_section(w, &snap.gossip)?;
402 write_row_section(w, &snap.recent_events)?;
403 write_pair_section(w, &snap.memory)?;
404 write_pair_section(w, &snap.fds)?;
405 Ok(())
406}
407
408fn write_pair_section<W: Write>(w: &mut W, s: &Section) -> io::Result<()> {
409 writeln!(w, "=== {} ===", s.name())?;
410 for (k, v) in &s.pairs {
411 writeln!(w, "{k}={v}")?;
412 }
413 writeln!(w)
414}
415
416fn write_row_section<W: Write>(w: &mut W, s: &RowSection) -> io::Result<()> {
417 writeln!(w, "=== {} ===", s.name())?;
418 for r in &s.rows {
419 writeln!(w, "{r}")?;
420 }
421 writeln!(w)
422}
423
424#[must_use]
428pub fn build_version() -> &'static str {
429 env!("CARGO_PKG_VERSION")
430}
431
432#[must_use]
438pub fn build_git_sha() -> &'static str {
439 option_env!("DYNOMITE_GIT_SHA").unwrap_or("unknown")
440}
441
442#[must_use]
445pub fn build_profile() -> &'static str {
446 if cfg!(debug_assertions) {
447 "debug"
448 } else {
449 "release"
450 }
451}
452
453#[must_use]
455pub fn build_section() -> Section {
456 Section::with_pairs(
457 "build",
458 &[
459 ("version", build_version()),
460 ("git_sha", build_git_sha()),
461 ("build_profile", build_profile()),
462 ],
463 )
464}
465
466const SECRET_FIELDS: &[&str] = &["redis_requirepass"];
470
471#[must_use]
473pub fn is_secret_config_field(key: &str) -> bool {
474 SECRET_FIELDS.contains(&key)
475}
476
477#[must_use]
498pub fn config_section(pool: &ConfPool) -> Section {
499 let mut s = Section::with_pairs("config", &[]);
500 push_config_listeners(&mut s, pool);
501 push_config_topology(&mut s, pool);
502 push_config_security(&mut s, pool);
503 push_config_runtime(&mut s, pool);
504 s
505}
506
507fn push_config_listeners(s: &mut Section, pool: &ConfPool) {
508 let listen = pool
509 .listen
510 .as_ref()
511 .map_or_else(|| "unset".to_string(), endpoint_to_string);
512 let dyn_listen = pool
513 .dyn_listen
514 .as_ref()
515 .map_or_else(|| "unset".to_string(), endpoint_to_string);
516 let stats_listen = pool
517 .stats_listen
518 .as_ref()
519 .map_or_else(|| "unset".to_string(), endpoint_to_string);
520 let data_store = match pool.data_store {
521 Some(0) => "redis".to_string(),
522 Some(1) => "memcache".to_string(),
523 Some(2) => "noxu".to_string(),
524 Some(n) => format!("unknown({n})"),
525 None => "unset".to_string(),
526 };
527 s.push("data_store", data_store);
528 s.push("listen", listen);
529 s.push("dyn_listen", dyn_listen);
530 s.push("stats_listen", stats_listen);
531}
532
533fn push_config_topology(s: &mut Section, pool: &ConfPool) {
534 s.push("rack", pool.rack.clone().unwrap_or_else(|| "unset".into()));
535 s.push(
536 "dc",
537 pool.datacenter.clone().unwrap_or_else(|| "unset".into()),
538 );
539 s.push("env", pool.env.clone().unwrap_or_else(|| "unset".into()));
540 s.push(
541 "distribution",
542 pool.resolved_distribution().as_str().to_string(),
543 );
544 s.push(
545 "hash",
546 pool.hash
547 .map_or_else(|| "unset".to_string(), |h| h.as_str().to_string()),
548 );
549}
550
551fn push_config_security(s: &mut Section, pool: &ConfPool) {
552 s.push(
553 "secure_server_option",
554 pool.secure_server_option
555 .clone()
556 .unwrap_or_else(|| "unset".into()),
557 );
558 s.push(
559 "pem_key_file",
560 pool.pem_key_file.clone().unwrap_or_else(|| "unset".into()),
561 );
562 s.push(
563 "recon_key_file",
564 pool.recon_key_file
565 .clone()
566 .unwrap_or_else(|| "unset".into()),
567 );
568 s.push(
569 "recon_iv_file",
570 pool.recon_iv_file.clone().unwrap_or_else(|| "unset".into()),
571 );
572 s.push(
573 "redis_requirepass",
574 if pool.redis_requirepass.is_some() {
575 "redacted".to_string()
576 } else {
577 "unset".to_string()
578 },
579 );
580}
581
582fn push_config_runtime(s: &mut Section, pool: &ConfPool) {
583 s.push(
584 "timeout_ms",
585 pool.timeout
586 .map_or_else(|| "unset".to_string(), |n| n.to_string()),
587 );
588 s.push(
589 "preconnect",
590 pool.preconnect
591 .map_or_else(|| "unset".to_string(), |b| b.to_string()),
592 );
593 s.push(
594 "auto_eject_hosts",
595 pool.auto_eject_hosts
596 .map_or_else(|| "unset".to_string(), |b| b.to_string()),
597 );
598 s.push(
599 "enable_hinted_handoff",
600 pool.enable_hinted_handoff
601 .map_or_else(|| "unset".to_string(), |b| b.to_string()),
602 );
603 s.push(
604 "enable_gossip",
605 pool.enable_gossip
606 .map_or_else(|| "unset".to_string(), |b| b.to_string()),
607 );
608 s.push(
609 "read_consistency",
610 pool.read_consistency
611 .clone()
612 .unwrap_or_else(|| "unset".into()),
613 );
614 s.push(
615 "write_consistency",
616 pool.write_consistency
617 .clone()
618 .unwrap_or_else(|| "unset".into()),
619 );
620}
621
622fn endpoint_to_string(l: &crate::conf::ConfListen) -> String {
623 format!("{}:{}", l.name(), l.port())
624}
625
626#[must_use]
629pub fn ring_section(distribution: &str, ring_entries: usize, tokens_per_node: usize) -> Section {
630 let mut s = Section::with_pairs("ring", &[]);
631 s.push("distribution", distribution);
632 s.push("vnodes", ring_entries.to_string());
633 s.push("tokens_per_node", tokens_per_node.to_string());
634 s
635}
636
637#[must_use]
639pub fn peer_row(
640 peer_id: &str,
641 dc: &str,
642 rack: &str,
643 state: PeerState,
644 phi: f64,
645 last_seen_ms: i64,
646 tokens: usize,
647) -> String {
648 format!(
649 "peer_id={peer_id} dc={dc} rack={rack} status={status} phi={phi:.2} \
650 last_seen_ms={last_seen_ms} tokens={tokens}",
651 status = state.name(),
652 )
653}
654
655#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
677pub struct RingPeer {
678 pub node: String,
681 pub dc: String,
683 pub rack: String,
685 pub tokens: Vec<u32>,
688 pub state: String,
690 pub is_local: bool,
692}
693
694#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
707pub struct RingSnapshot {
708 pub peers: Vec<RingPeer>,
710}
711
712#[must_use]
736pub fn gather_ring_from_pool(pool: &ServerPool) -> RingSnapshot {
737 let peers = pool.peers().read();
738 let rows = peers
739 .iter()
740 .map(|p| RingPeer {
741 node: format!("{}:{}", p.endpoint().host(), p.endpoint().port()),
742 dc: p.dc().to_string(),
743 rack: p.rack().to_string(),
744 tokens: p.tokens().iter().map(DynToken::get_int).collect(),
745 state: p.state().name().to_string(),
746 is_local: p.is_local(),
747 })
748 .collect();
749 RingSnapshot { peers: rows }
750}
751
752#[must_use]
754pub fn queues_section(snap: &crate::stats::Snapshot) -> Section {
755 let mut s = Section::with_pairs("queues", &[]);
756 let dispatcher = snap.pool.metrics[PoolField::DnodeClientInQueue.index()]
757 + snap.pool.metrics[PoolField::DnodeClientOutQueue.index()];
758 let backend = snap.server.metrics[crate::stats::ServerField::InQueue.index()]
759 + snap.server.metrics[crate::stats::ServerField::OutQueue.index()];
760 let hint_store = snap.pool.metrics[PoolField::PeerInQueue.index()]
761 + snap.pool.metrics[PoolField::RemotePeerInQueue.index()];
762 s.push("dispatcher_inflight", dispatcher.to_string());
763 s.push("backend_supervisor_pending", backend.to_string());
764 s.push("hint_store_size", hint_store.to_string());
765 s
766}
767
768#[must_use]
772pub fn gossip_section(snap: &crate::stats::Snapshot) -> Section {
773 let mut s = Section::with_pairs("gossip", &[]);
774 let churn = snap.pool.metrics[PoolField::StatsCount.index()];
775 let alarms: i64 = snap
779 .failure
780 .peer_phi
781 .iter()
782 .filter(|p| p.phi >= 8.0)
783 .count()
784 .try_into()
785 .unwrap_or(i64::MAX);
786 let transitions: i64 = snap
787 .failure
788 .peer_state_transitions
789 .iter()
790 .map(|t| i64::try_from(t.count).unwrap_or(i64::MAX))
791 .sum();
792 s.push("churn_total", transitions.to_string());
793 s.push("phi_alarms_total", alarms.to_string());
794 s.push("stats_count", churn.to_string());
795 s.push("heartbeats_sent", "unavailable".to_string());
796 s.push("heartbeats_received", "unavailable".to_string());
797 s
798}
799
800#[must_use]
803pub fn memory_section(dyn_memory: i64) -> Section {
804 let mut s = Section::with_pairs("memory", &[]);
805 let rss = read_proc_status_kb("VmRSS").map_or_else(
806 || "unavailable".to_string(),
807 |kb| (kb.saturating_mul(1024)).to_string(),
808 );
809 let vms = read_proc_status_kb("VmSize").map_or_else(
810 || "unavailable".to_string(),
811 |kb| (kb.saturating_mul(1024)).to_string(),
812 );
813 s.push("rss_bytes", rss);
814 s.push("vms_bytes", vms);
815 s.push("hint_store_bytes", "unavailable".to_string());
816 s.push("mbuf_pool_bytes", dyn_memory.to_string());
817 s
818}
819
820#[must_use]
823pub fn fds_section() -> Section {
824 let mut s = Section::with_pairs("fds", &[]);
825 let total = match std::fs::read_dir("/proc/self/fd") {
826 Ok(rd) => rd.count().to_string(),
827 Err(_) => "unavailable".to_string(),
828 };
829 s.push("total", total);
830 s.push("listening", "unavailable".to_string());
831 s.push("peer_links", "unavailable".to_string());
832 s
833}
834
835fn read_proc_status_kb(field: &str) -> Option<u64> {
839 let s = std::fs::read_to_string("/proc/self/status").ok()?;
840 for line in s.lines() {
841 let Some(rest) = line.strip_prefix(field) else {
842 continue;
843 };
844 let rest = rest.trim_start_matches(':').trim();
846 let mut it = rest.split_whitespace();
847 let n_str = it.next()?;
848 return n_str.parse::<u64>().ok();
849 }
850 None
851}
852
853#[must_use]
856pub fn render_timestamp(ts_secs: u64) -> String {
857 let mut out = String::with_capacity(20);
858 let (y, mo, d, h, mi, se) = secs_to_components(ts_secs);
859 let _ = write!(out, "{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z");
860 out
861}
862
863fn secs_to_components(ts_secs: u64) -> (u64, u64, u64, u64, u64, u64) {
868 let secs_per_day: u64 = 86_400;
869 let days = ts_secs / secs_per_day;
870 let day_rem = ts_secs % secs_per_day;
871 let hour = day_rem / 3_600;
872 let minute = (day_rem % 3_600) / 60;
873 let second = day_rem % 60;
874 let (year, month, day) = days_to_civil(days);
875 (year, month, day, hour, minute, second)
876}
877
878fn days_to_civil(days_since_epoch: u64) -> (u64, u64, u64) {
883 let serial = days_since_epoch + 719_468;
884 let era = serial / 146_097;
885 let day_of_era = serial - era * 146_097;
886 let year_of_era =
887 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
888 let mut year = year_of_era + era * 400;
889 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
890 let month_phase = (5 * day_of_year + 2) / 153;
891 let day = day_of_year - (153 * month_phase + 2) / 5 + 1;
892 let month = if month_phase < 10 {
893 month_phase + 3
894 } else {
895 month_phase.saturating_sub(9)
896 };
897 if month <= 2 {
898 year += 1;
899 }
900 (year, month, day)
901}
902
903#[must_use]
906pub fn recent_events_section(events: &[RecentEvent]) -> RowSection {
907 let mut sec = RowSection::new("recent_events");
908 for e in events {
909 let detail = if e.detail.is_empty() {
910 String::new()
911 } else {
912 format!(" {}", e.detail)
913 };
914 sec.push(format!(
915 "{} {}{}",
916 render_timestamp(e.ts_secs),
917 e.kind,
918 detail
919 ));
920 }
921 sec
922}
923
924#[must_use]
933pub fn gather_from_handle(
934 handle: &ServerHandle,
935 pool: &ConfPool,
936 events: &RecentEvents,
937) -> ClusterInfoSnapshot {
938 let stats = handle.stats();
939 let ring = handle.ring();
940 let peers_view = handle.peers();
941 let tokens_per_node = peers_view
942 .iter()
943 .find(|p| p.is_local)
944 .map_or(0, |p| p.tokens.len());
945 let dist = pool.resolved_distribution().as_str();
946 let mut peer_rows = RowSection::new("peers");
947 let _now_secs = SystemTime::now()
948 .duration_since(UNIX_EPOCH)
949 .map_or(0, |d| d.as_secs());
950 for p in &peers_view {
951 let last_seen_ms: i64 = 0;
956 let row = peer_row(
957 &peer_label(p),
958 &p.dc,
959 &p.rack,
960 p.state,
961 phi_for(&stats, p.idx),
962 last_seen_ms,
963 p.tokens.len(),
964 );
965 peer_rows.push(row);
966 }
967 ClusterInfoSnapshot {
968 build: build_section(),
969 config: config_section(pool),
970 ring: ring_section(dist, ring.entries.len(), tokens_per_node),
971 peers: peer_rows,
972 queues: queues_section(&stats),
973 gossip: gossip_section(&stats),
974 recent_events: recent_events_section(&events.snapshot()),
975 memory: memory_section(stats.dyn_memory),
976 fds: fds_section(),
977 }
978}
979
980fn peer_label(p: &crate::embed::PeerSnapshot) -> String {
981 if p.is_local {
982 "local".to_string()
983 } else {
984 format!("{}:{}", p.host, p.port)
985 }
986}
987
988fn phi_for(stats: &crate::stats::Snapshot, peer_idx: u32) -> f64 {
989 stats
990 .failure
991 .peer_phi
992 .iter()
993 .find(|p| p.peer_idx == peer_idx)
994 .map_or(0.0, |p| p.phi)
995}
996
997#[cfg(test)]
998mod tests {
999 use super::*;
1000
1001 #[test]
1002 fn synthetic_snapshot_renders_all_sections() {
1003 let snap = ClusterInfoSnapshot::synthetic();
1004 let mut buf = Vec::new();
1005 format_text(&snap, &mut buf).expect("format");
1006 let text = String::from_utf8(buf).expect("ascii");
1007 for header in [
1008 "=== build ===",
1009 "=== config ===",
1010 "=== ring ===",
1011 "=== peers ===",
1012 "=== queues ===",
1013 "=== gossip ===",
1014 "=== recent_events ===",
1015 "=== memory ===",
1016 "=== fds ===",
1017 ] {
1018 assert!(text.contains(header), "missing {header}");
1019 }
1020 assert!(text.is_ascii());
1021 }
1022
1023 #[test]
1024 fn config_section_redacts_requirepass() {
1025 let mut pool = ConfPool::default();
1026 pool.apply_defaults();
1027 pool.redis_requirepass = Some("super-secret-password".into());
1028 let sec = config_section(&pool);
1029 let pair = sec
1030 .pairs()
1031 .iter()
1032 .find(|(k, _)| k == "redis_requirepass")
1033 .expect("present");
1034 assert_eq!(pair.1, "redacted");
1035 let mut buf = Vec::new();
1036 let snap = ClusterInfoSnapshot {
1037 config: sec,
1038 ..ClusterInfoSnapshot::synthetic()
1039 };
1040 format_text(&snap, &mut buf).unwrap();
1041 let text = String::from_utf8(buf).unwrap();
1042 assert!(!text.contains("super-secret-password"));
1043 }
1044
1045 #[test]
1046 fn ring_buffer_drops_oldest_when_full() {
1047 let log = RecentEvents::new();
1048 for i in 0..(MAX_RECENT_EVENTS + 5) {
1049 log.push(RecentEvent::new(i as u64, "tick", ""));
1050 }
1051 let snap = log.snapshot();
1052 assert_eq!(snap.len(), MAX_RECENT_EVENTS);
1053 assert_eq!(snap.first().unwrap().ts_secs, 5);
1054 assert_eq!(snap.last().unwrap().ts_secs, (MAX_RECENT_EVENTS + 4) as u64);
1055 }
1056
1057 #[test]
1058 fn timestamp_renders_known_epoch() {
1059 assert_eq!(render_timestamp(0), "1970-01-01T00:00:00Z");
1060 assert_eq!(render_timestamp(1_779_840_000), "2026-05-27T00:00:00Z");
1062 }
1063
1064 #[test]
1065 fn is_secret_reports_known_fields() {
1066 assert!(is_secret_config_field("redis_requirepass"));
1067 assert!(!is_secret_config_field("listen"));
1068 }
1069
1070 #[test]
1071 fn peer_row_is_ascii_and_well_formed() {
1072 let row = peer_row(
1073 "arnold",
1074 "dc-arnold",
1075 "rack-1",
1076 PeerState::Normal,
1077 0.42,
1078 120,
1079 3,
1080 );
1081 assert!(row.is_ascii());
1082 assert!(row.contains("phi=0.42"));
1083 assert!(row.contains("status=NORMAL"));
1084 assert!(row.contains("tokens=3"));
1085 }
1086
1087 #[test]
1088 fn build_profile_is_one_of_two_values() {
1089 let p = build_profile();
1090 assert!(p == "debug" || p == "release");
1091 }
1092}