From 980442786c96190c579b3625eea7bf47cf501155 Mon Sep 17 00:00:00 2001 From: JuanLeon Lahoz Date: Fri, 30 Apr 2021 17:39:44 +0200 Subject: [PATCH] Implement timehist subcommand --- Cargo.lock | 43 ++++ Cargo.toml | 1 + README.md | 30 ++- resources/timehist-example.png | Bin 0 -> 27282 bytes src/app.rs | 154 ++++++++------ src/dateparser.rs | 367 +++++++++++++++++++++++++++++++++ src/main.rs | 34 +++ src/reader.rs | 124 +++++++++++ src/timehist.rs | 172 +++++++++++++++ 9 files changed, 866 insertions(+), 59 deletions(-) create mode 100644 resources/timehist-example.png create mode 100644 src/dateparser.rs create mode 100644 src/timehist.rs diff --git a/Cargo.lock b/Cargo.lock index 3d08e68..1e64561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + [[package]] name = "clap" version = "3.0.0-beta.2" @@ -234,6 +247,7 @@ checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41" name = "lowcharts" version = "0.2.0" dependencies = [ + "chrono", "clap", "derive_builder", "float_eq", @@ -249,6 +263,25 @@ version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + [[package]] name = "os_str_bytes" version = "2.4.0" @@ -433,6 +466,16 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "unicode-segmentation" version = "1.7.1" diff --git a/Cargo.toml b/Cargo.toml index b9c2ff6..54214f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ yansi = "0.5.0" isatty = "0.1" derive_builder = "0.10.0" regex = "1.4.5" +chrono = "0.4" [dev-dependencies] float_eq = "0.5.0" diff --git a/README.md b/README.md index 975a577..6051833 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ terminal. Type `lowcharts --help`, or `lowcharts PLOT-TYPE --help` for a complete list of options. -Currently three basic types of plots are supported: +Currently four basic types of plots are supported: #### Bar chart for matches in the input @@ -95,6 +95,34 @@ of a metric over time, but not the speed of that evolution. There is regex support for this type of plots. +#### Time Histogram + +This chart is generated using `strace -tt ls -lR * 2>&1 | lowcharts timehist --intervals 10`: + +[![Sample plot with lowcharts](resources/timehist-example.png)](resources/timehist-example.png) + +Things like `lowcharts timehist --regex ' 404 ' nginx.log` should work in a +similar way, and would give you a glimpse of when and how many 404s are being +triggered in your server. + +The idea is to depict the frequency of logs that match a regex (by default any +log that is read by the tool). The sub-command can autodetect the more common +(in my personal and biased experience) datetime/timestamp formats: rfc 3339, rfc +2822, python `%(asctime)s`, golang default log format, nginx, rabbitmq, strace +-t (or -tt, or -ttt),ltrace,... as long as the timestamp is present in the first +line in the log and the format is consistent in all the lines that contain +timestamp. It is ok to have lines with no timestamp. The consistency is +required because of performance reasons: the 1st log line is the only one that +triggers the heuristics needed to create an specialized datetime parser on the +fly. + +However, if you have a format that lowcharts cannot autodetected, you can +specify it via command line flag. For instance, `--format +'%d-%b-%Y::%H:%M:%S'`. Note that, as of today, you need to leave out the +timezone part of the format string (the autodetection works fine with +timezones). + + ### Installing #### Via release diff --git a/resources/timehist-example.png b/resources/timehist-example.png new file mode 100644 index 0000000000000000000000000000000000000000..50b4d501a25c26ceffada2c06c38f69038f01d5a GIT binary patch literal 27282 zcmb@u1zc6_`Yk#s>F!2AQUs+-xCsZ6G8ZiJ+08*ktDz2$Jvu-LlrmbApwoEO6oRNqKGAbQKICNRPn3x21 z0(CbN4Iypl2U)gOBGbJ${>MBH1JrHT{T`y&v^fUN$(@R<5Sr(SURcau-$}{pD5!{@ z)nsj&n47uhG!>;oqG{x&1%Gfn54-SwHJDm<`4gg9#AQlWyyb>;wljG=Wmyg-jPN)R z+oSRDrFU`b%sj|vIV zsJAmvR2<37{TLV5a=BF;j7HkHy4%7oyqw>|{A)&2U&Y=t!6Etuy>ikEWmBm_>GopgphIiK92k#Lq&>C_@-KFoNs|V_m;Fo zTAL)EO@dM#>haIB%e-)MLgH{+w+u}1?oN)yVSF7Z%&I1pl$6AyNDu*s^}(9y-MR*I z0bD7&diO1=pJ$ICX<3M@5y?wM*DY~(P1*}PQZl!6q?oHHk%Kz4>iKFJ-{@}-?sms? zJ~}T8TW)G>(!#(z9Ri^S%Qc@x!BHrG&*<#@{+upYq>*e}wdRkhS`2siJz3#Tg zi|=mseJt6Mt4xP%rmDZ+_?-r!5)Y>F=8u?_>9sW4&cBQvgoTHf5EGN4#+tv9ACbMj zUT0gMbI^#~yXtG}w`p|kFK8IeZPD|35o{v%xDq?9C%E&~nC^vo!kK%W;)LsMfQ@p> z3%9*YxGA3Gtv23KwF{>GjWaV5neP*&t8ltgO3DtJ)?cB*vIBCuFX!MMB1B*qN9;Mu z@(h>z$p%$4ALWPd-iP7(p5XnL1yE8^$*1#O{4$CjNM_$AzdP?qv2Eq{x^x6Kc6Pw$ z1}_!@6irRd!TJ5xt1?zb3W|jf@-L-iWIWC$O<7r4=Uta0>+8AHlYZpMDKo?|s+Y~Q zctCuvdwlv6pLaCe9rlpVR~q#Y+cw(|@k6X;8=X3WP}?pBY*VxP`}-60+&AOY^^$m; zw#vOvi^IZ@q6e*hRb&+unB87qv_kaLDyM!HvX7-g-l9;U0hwIIm;hZ^9-S5jm-@$p zsLX@F6b3trYRMvLZ4{iuQ&sOzRQHd3QMTS9n4g_Z8E;%?GwZ%JM*rda%GCY6GE%;cn{rQKa*+|eVnpKecNc%4ns zE9ZIM-9UhYc4X$Axt1$1SYUlGFE87`9ly^#KNxs`;@Pu7i<-6db?v8krD`6ly>XA7 z^~lo5l39+m_SE)+3t82sd6K7!hoF7G@X9tGg;XL(peB&U4-R)~j$$g-M&+e2RFnf^ zC-ti3N;m^fDGaSs#y{0BM@S!balX=@0>OV_>h_=m9ek)plK~<~01i^u+Ca*Be-bt( z=GV_wl9J!L!tvpmc=-8S%E|`0VRwp)ih5OTwY5`(vj8Ahi4YgJ#(KII{Lv6qt?_h_ zT;Ka*6?})G3;`doc4TK~gHNMTKGBrW0On~Lugm3_Euh8_ozj%Grh;Z$=gR%mqF1_s zr{$#TPQ*3XL4IKK#{QTiLcb~)<7-JV%UJ4%D1|G%*KJnfx>9=?8ih}v(mRZ$n)bH+ zve~w3ofN*WSb?5BN;qPQA1$vk?ymDEtzJ59smRJkSgyBvU4aiI!`z~xtlVKgv)B8_CY)e2wJ>= z!g+;BfX9f0BBgU8EDvLb2jua29c;mCIx81Dl% z`(P^f_RrF*vuRr>!KZl9Wc-(49oXw}ba4q6rHsxl*J=3dygRkAVHPRAY-FjWrB$tE zZDL~5mLmvMOz-B)XMknM@ztwWVq#q zoxt=fzFN!dFtpnXO~36M4oou6xSRL3?L$OMS*QpNXkXc48)$%d(hsZS$c5V^F1%H~ zdFUHQaJx5AqyU1yg0eEJ%breho*3nb{_Rl$CY87=iL$1q+q@TqK2ADRbSc^L2+Wa* z@$s8w^1DP<)4>A896TnilYZ7Al&Q=KJrC>MshYN{*?lZ*Y;d7}mh0~)3pG1#j^<~0 z^E`PXR;ng&*oo~=@i9KWalK5xt+k@UZm!v_^+ac3r#6aa!Ux(EpJWz%MR@>VupWmV zR3Z}%lH_Shz5Z43r9yEjQ^C|w!BzPD7u*ZgrH~P2tb{j{1G}ZUwD3$Z*=DBPY>Xz< zpTTb&%r&J8dsPo{F)%Xut`hpz$zo>46b5G`sEBy3p}Ei-?3Q2=@IPJ6G|ljG?ujB* zFVm@$c8jP(0pfHTfCLjNSP*eVPxdYHEAG zDvgF4(a2v~Rcdh9%p}Ie8FQq_NK4OoUs;=eGh+iE&>P%)!^-;X?4|quQqrtiMJJ4H z!a`}@?sc~~_2cc0=q%?x06g~WtcQCaBk8xzVNqr=*xJKu6@Vuh{%Kjw#^p9XC_*J- zu)@hAT;LmJ-~pQD*vGhRK{!kdS)dA=Y& zLqXry&4{z-A+4?bE(3zv_xbZ@b$zeX;VhB5rpE$l!((-bR*6wOwf*0RQLCm!4g$&*;X0_G*)!lgCVS@9;49cyD;5ho@q9e!@ zO7d$b3!OFhITH&eW&jXcIO*zXYB~vwIrykg{cMoo!Yrkxsipym$RXnG`{d5Z~p)<%Nbe534S#t05#k1Y% za^0pi?`&aV;S={3y>fLr9B?ZgZ@sS%d%OxQDdw1S@nLk0!?L|0P4l{qj)j$#htsxg zx-}MYj9trdzRk_eU`n7?_Q4#^p|t4Tjmj`b60!GcW-`kMQqeMVZZZ@dL!3Eqk{nl= zCXRLWLt+cDA|t!R2XLDjT1v0yIgA_>MEzOEl#ZM%-GmZq$HsKjwC#MeHx>#i1Ae{( z({z@s%r+Pdt79;-Zpf;@ZX8L>`6`vmeyl)IUf`zsRes7#uSQlJ42*+Wm-#e4x3^%) zwYRrN=RX++;Z-&9RtBzCu#u6``cQ_?>DIW@_D^o*w{PEqZDy** zl0Gb%#Xwk2Zs7eBnINUtVEuM*I3CTFp`xPdi6*}b7P$VZnw(Gq-ic)m`0W#;Z2YE! zsjaj{B_;jK1}%`r=7YENEeCJoCQ3AOM$E{(PDakp&*$%MA?_{SM=RgUyxj=lE=P=+ zo7p-x>)LL11%2jjlFvn7t*opX;oW*KVRM6}0$o07AE}U)Ed8LEAFBZg@TXFy5jcG% z*$*4-s6|c6ed`zDB;illeXd7;pCupM!BDxooM?mg-ToQg2e#EQ+ujL%pWVy09wnY})CWn3z}!r>(QK%}o6v80CZW zU=-p%)3JMZdsf#*%J1$-2Un)sRGXJa!@^Qwd))kH7eq&3IdOT{dUxv)jXk~}^G!y# z)uHX9o@+xd?W-Hk4^rL*MRhgo2i zFRB(J93nxKOS}+CR5C3YbY1)Qah@dkdW_c~v})D?toO?4+^Lm&v!U{P+q^uYU}4)M zX{i#)9P;o3`{y7z(mTWBwE++!gocE?*5sh3URml2A5?I6ch}X`1)+RfuDYsfgC38S zrKR!=*Y2`}Y27rx-n9}#oT8#4h>{%0w>6&PP3&)NSwQBiK)@yK_h0bR)hG%C``7sR zYF{W(!L$8l9ckufoz?pK`n%ixJ7;UX7I&xX^TPl<1aGHF z*EjprtF_Ww$lr@YV8$^qF$Hks%xg6hgU_d__beBGuKcsdw;Rc5oFIc4&YO+TE->sJ8QHGz`xTnJ z)n)v%;w?I9Ti2w#$DwP*`nBpAaDb&pz|*%@W1m*b6e$)-)dC&?jn%n@#d@UN2Ik<< z(x&ZyUk`W!%5gS49kwGm=gvV`m6Dvy!^0yUO|~1R*YMW(WMc%p>M9~dhDS%U$1T7S z1fGZD$&)U23|Tojr#AKteLv@>8!%RRopAHp5T%8=IQMZus%`>z*+; z>pyHza&q#?iQ|NcmR7Q*lA@_;zATM{Dk(1Rs*LU{7$-Lhz2rr58OumE(bXv&c*fl| zXCTuo2u})XCMZ!fQ`7z~SAmV`JxVSixCO(dBG|$@bq1o8ipeP}D{lY{*sQ!flf9V+ zHo0)o?6&JwW-9RryHy{s@H#m;frFO7T7MF&#aMw@I3E9TKWkk+BaG{A&BV_#-K(bg zn`dBdGBH_$y<)E_4i~A5vAp?(Nq-`{<|TzY4ux*%9x4^D#I;H02q~CbMmp*3G>QdZydR zX3!o8-g8)XI#@qrVlc?Bcoh;_4n|S#sz0s-cgiZ{GQAHH4a&c!q&D$)U?QMANK0lc z8WW>3q?Hj7XZY{}*IpU5<{coY=-A%UhD^*VlPCSLSb2HvC2?j5he{f$G%L8XS=zx) zK6ZI(#qz0Ki&JA3HP|$=fbX9W+BU%-(b#hS&X>$T<1zJgq;p%yUg!A1yT*Y~27_oD-dfpzzt+!j}r+!t3F?v69=?x+4nCreRL zQC;0xFelcN?T)+f^{!2pISbz>x`#gGCOs5R95*6NC%Lvo#n}pbK-kF0s=AS3?h?1E8!GSK$~t4tF7-p zA4@0yYo#^@IOne?y%iW~Y1d6ph0q>AUV`=!oWXCn7o5YNXYs&qWMu>ptA$-OOxD{k z@7mnsqrl-^p^hO}oDgu$AD>i-b(HG&6aEH+=6t5XBs`9i`9I8Src{g7X5$~tcGX%< zf{6rn&7Y;(whV`8ZFJrc+e@WLzc}TyuQXl)@K<7@0xV_S(VJOR?)vH^5mWu9oYmd6Z}wv~bDp zsT{t*3y!mrR|%Da4uuU-mojlZ`o~T0aHzrtHA*ED2c=h?m9=Fy5ohCkxJ61t^U}{Y zLVksoh?=mQfy@ajhn@rOdvII_uY?3nGndOP4@I5q7aI|$C3{77&hkT$OQm>LDF~#j zdd8Z%N8gUir)MzR$n^PFMI6A910gr#U^ni~@KaO6r5jma{}=2ZEF~`Dw_84hU2i0O zG+ws7wzD(0EEYrvVhMbVVt^pxqQ4Q<1r5ldf7U}82;(lUEIiNv0Xtb1Am(@!;ImiI zrV-bcP^S$oZ1klLO&c|moz@|>E%b|r=##IDTZPdY)5M-zu)Y*_mrKjDy6(b~uD#44 zVHM|DJusz*4m|0(-QkJAgr}Zu8&}xHBI|x&?QI6RF^&KljWnW!hV;6mRPuO& zw1#ICPxCHCqh@EyWYbgXH{Mw`Xj`WZygX}1_mj5RLF)ds;WpS*y|SduA0P~=B90TP z)4}g;qkx7-MBYql!^geWmq>_r9SW1op)E6(5m-!NMm|JN+#IT;)pt%jy}dK;VZ;em zFR`2d;C7JNwfF_&_bP1cA;nb`GjLXB&0avj$e4BrO@a$ z^gu;$`C`J^)jsoUTNK5ZvoSB;uHy+aSy~s!=Z5H=|MIvJzhbJ$U9p_7t!jN?)n5W) z!a>DM4<@kjH+O6;)7fR>^{Rw+cN^xcApzBKcf4fA$9MU2zMe;F7?=6=0ewFFudvTOVMh{ZQt4%{;F_V`{M8qcnyi1%UE*B06Ioo8pB z?8dcw@pe{1hF9+z7k}y=DbW~`oXT;mH@a}Y#)AU{pMRPBGWE)pcNm2NEXHQ_MsPw0 z9^+>1ysp6mQ&~>Cn;cuKTVp8g4!91y=7q9!~m4z`|D&`&*9Sn%$scb56gS zNLaBMmSK6#?#{uyHm^w5x)Yzx*(z8x61*5#k<$tPy)XbUBy=RU6)Jz|QP@OV_$yCK z*+((rER{)0s^_`(3_ATvsJ|KP;&6G~F~xlAe36@T1`Q-Su69Y()eHZV4m(yXiH3s) z?r~WCeu!ktkO8~%*y%CDqFhZ(HlY?ZoE@(b2-PaD4*ff5xU7~kSsHlc9QInEh`C{R zRog@HJu=|rFWLKezs30+&3chtg#}PhM2za~c%8DI8qioU-0Z#@?4#C?eku})5kGcJ zNg*b(LD0B}()v5EY)hEo6>&&0h+u$!kW;F84ijfz%EnFMGRAi%U0~>4TDo~9d}^4)!}8l7(^1TQ_PLh*7f;HYB5JW2 z73&ex!4i-g7hE;7^x!9^P&X~9O_@&96yTXd%QIQ3m2#EXPp9A3m^J=7BTfC5!T!03 zYr$2XDPO`F9X4-xgJX=*6AUa7zvJ2rKi8q6L41?zc)8|`5na3`JeCqz3;8R79I!}Y z2)jV5VFU`TBmAJ;tdE;cYs-C1g`2}_l{U46{u#(@x%5zDP|jrzO+VA9E38@Uk3H*U z4N#XEdH!RfLWyv~nq!;6^40$Sp2cAk`XAAd3n>PN2?rI@L1Occ3$msBO#;vI;;+dz zliaUp4jw;pqf>kc;IP^yBD5(GLviYDa@LC0c08Ip)?C`I#T8j>*s0L6N$?B{XvBiK zWGsgOMgBOGT$aLad2?pw3*pxm3XQwF7Lrc$EQcBUK5{I#N^t?PlS`6nSL4aloASfYVCyE<8pX* z+iJ5do>s~8cJ#$WUdNQ%#jG4zPhhuvw0=cHRcpc0d6`gC)*r+@4|lrQ!hC)nN@q&O zSWQ$Skav!VoOr}E-1f}F{1NcR^!{33T}9RM(Dz~vlTmq;R+&Doi#xctc|m<3a@9rF zvr&d%3V90%RlWF5 zSsuDnXdox(qt{^F$CI+>n-XoGo)VWU)bG<b!)c+HV1{NQEUVolAfIu^D7i}o^*3m58k)?n4E4YkN(5D#bApM zV-Fr+AkqLrvlaV~c{jv}p`<#S)5tWDM#NFe(C()fRHK)X&@C|%kw0{= z=AR?`Fe^Wq8VHJi?-`9=Q#hJ%awE+QNJ+ccvzsnyUr#&|(o*agqni8^* zKX!sk&%k^0gvFgq1d71okhlFFO&)Y(3192I06UTz19_6S&++OIs9nQJU zRyiDhR@Dl&jX8tSIEe_|*H-mA6^=nPgYTn4K0C2pOz0nE_1$nRt0DXau+E&XI1Ndd z!ZQaOP}h22uKM|3l!y5Ge(6}HN^R8ufMh6p7V4*O7}}QlUOEIm8Cmw}daG7;=qYbw zhr_-vhA9>>i!#5zh%#Q^PYqTrw&uz}450%^E1DRLucm$>(wwQloJA1RY32^%P~mgdIN=UUP(rt3|%3ie;3Ug^E%#Ja{76TM4Pt|Y; z-q&xynTBL*0*OZ_UyI4#fFLY@qx(XmK}I{dacevlJN_pg3E=w&0Cgo-Gn|9BuS-@y zC{Pw-riCRuaBe-cnnM8i!aHCtpjf*%j*#q8r4KG`E(`)#t-SD_1kXh=V0?9V(H_Y1@MtySMQp{WppkC9F z$5vJUloU}Zle4cF^2?_LFNWhlu zr34HHcfABP2EmbP$)#2iyI-)uawIr3>iJ-`a8YJzo&@WCcXV!xG}ocwe$|3hn~58I z!;ySzQw~I_dmv|5d{k%5BPYScHx{VZwWb7)JBnVAIteR??h|MG8GIZVz9WAmc4~6p zaLp2r%yS9CvSEac0AG`?2vIx&4(SEb!MbfN>7!+QE2UhfEvtGgk|Zn;wXTi5gy^VV zF13E(URt-^DrkGNZ()#o^et2d5mQS>9#q&TXZ5GBxp*(KD4F4ua7aP^L1Opa-ZTDz zu#WD2GzC#k-EU6fAb$ZJms7fXYUQf~It^@WTz;;$yz_RP^kDVYX}$@%a&AsLh$22? zF$_uT8cSW8#b3m9HEW>hWXg<;{3IjOj#c>WyJMF+zr+|Wf^xdSGCs?tr7&0>V#W2{ zgJFQMAdrVRd;i+zaYSNRk`#P#;pmXvL(6AtzBmz0#FOeNo!Pr)LM1FDNHf?T!}E!O z19r#ftCW%$yrfR5dm80RQB9+&)zb-^MZ=U5HG5G+|v>W%c_dh}!@+6o3FV_~^R~VWB@37Yq9b`=x8&1g1hgkt5(6}$&oGm$y zk-sg;FNltjQ6^{PQ(oTD^#K2?mJa5y`3J>%c(>XEvPa76JhP{EKly-z9ayz-PQw(PqZDj~@h7iF61wh}Ay**sRvtEmAGEkqOu{)ULG}v8Zux znTzIbcR3O}Z2AkfU1~D3aB{jVcSr7dbiOb?sg?+6Uw-md)pD|M%D-+V2OPRVw&oip z7Vu?MP2JuT-xhM|qzyQAZ-Jw+P70dk`l<`38woa%0KfOwwZY45(0k;$FW+`Q7sg_f zWA(L{1knsk-f?i?p=-SYbq)SA$1(jPzFQ)oU=%O-$w2HkRl$&&me$QBi8APxH&BAk z50{=^`u?o_=6|qQw6jb5#)c-NLW?*60sE&Nht}Lrrfves?y;0r#s3A^BEG6}9UTf+D*sUV9zeW(q!HFrH&LfBg+nzh8lHa`lY}G8h z*qUEmqe=lQat|U{0<(Cz9TN=T3=UR(%+>leBf%1$o#$Gnf|VKa%KKo5^c27N2y)~s zBWQ+l?wni_)D5x?mJ66)9o}L7)6S(Ic9f2b#cJTV*4T|q?mVWelFLL?wSoTRfu5QR zXW%|Fov;-riE+GB6FJpf&-*hQw$Hzfhp3_iLc#8C;=zUmit&r)T##hmu`;HmMlv;c>=~R1ap)offYg7+fxe zcyI_zIBPr`?Ew@Z1H6x)W5?0A4X56a@9R3-rZ8Az?pHK+J1{-bZxS}~G6tE2uZl)# zIfBfZbulS1+W5eB>8ygr-EZ zXMbcIi0M(_UKi|g=hes}$YuId(g6rEpP<_d*7`tSCMphU=dKj^ z9%BhYVi0DcQRw;R@@61Gz?Ojzk0j}Z#hG$*$CTb_8bs7VM%DVOpH0pyoPvZv_nzWr>c&QYVf)T<^CUok|~Q#@%i-^CW^lh=H>beqiP$BSpKdfOMea z4aa70&)pHK@!Pz!QQ8s}GENtffkKiw8^VIYRyg;8Y`{00V9uZ_MGMh3Khc|}(-rf% zO;%-P=Q6)=ilX}aD_S)MWSL$P_VuJA=2{PuVcVb4k|Zb}G7ORC*GOUdJDzWUFH|R4 z5)5ewPCBP{SYtOF#RNkBjHQ5QHvuUpI87|tqOfM=lydKc?WpIFALLgpPEP70!GG5P z-Id>g9B@;iD(DXte63MJ$3ksCf;@;JRt2!EZU(3eeEXcD1<&x+1JAau0_tdRshnq< z846&Gv;X;ue3}L^3L!K}A+D{*NZ{|9p`TEdx9H=MYU;q`!|C)siRS=-z7}#^$6aC# z3&5f^5+MTBs&>bEIFXwDIHXGe#jav4L$qxp1>jp5OZOr06Fk8>-Eax*{El4()@y2& zy4e16CQJ62cgI;^M|r%$R_);jdSZ#pLwalI(bT2}cK6^}mIT$XRw|s4Qd{ouB;Ob& zjVgLT0e=~_vPP1&L%xiAISgM^xMo7Pm?oXZeT_9rdmLu+rsz}cXJ~S5f>`&A4vU=v z#uH~Gx@0^l8y-uusp3Fv=ChZ`=_i))E-{RED^0wY4aqog5g;Ob08gaCf$1^G6R0|z zhzPmuJtI%Z-3MpI?^~d5N4_G3*a%eCb8wh86sJT+A%1dzrY(UDfMhjj^8F;OJvM3g zgzLo&q)9OL_#G<{+)y( z6lQJ)xoREl1GSY;!P$8w)lO`xBt8Q@6Q}Ln{U)MNWFq3ipfONfPLm!<1;ek($*lMo zLSQlEF?30f37v}iKH-HD+!MRk7Bu*04rOkg321NW`zBA=BSghDQ9oTHw=VUiXQhAX z*l3856|;9XqsNqf>o)jPnTGLQ!?ft&{0m%M?obYPY)DEh_)Hi?-g-Var~d;6$}G9n z;Jcs@Mm&>t-iVo6*~zsm0#SBJ$XGlD`&;Mej+$qg%KwYOT6|R6*XAvy z$)T5JH?KT3#em`en}Z<|f6O*h*~*8Or}^BJodcIFJb9@>Ny?X!=i;k?%b0#i?|k1h z6PTw9)A~WPnsn$uD5wvM9z2#@S8_Nwkfi1c6FerQ@_t{Lr!fe~D=xz@IDCibgzzj=mp7~TYm>L_4TfQ5v9GP7b?-fTcE zF~VX@s{OvNXQ0UdSBlx`uaGuRChOh7a2u;HTigD3H|b zE3Ki?>(tNOzg{4Wi~YlV9(;?Uk_mZAD^3Ck+N*79v z5?C;>=m3$@1;U)yCw!nnzjQ@L3vtSg@V^mX`Q`mA@!fgELKbfMVyktjZrwdxHuw+PfNcLBopnZs4 zkj0QhHRiIJCahCgrfH`*D+O9m;5*q>bvE03UiNf+fWnicG+jEwz2Pq9 z`nPc}Yw_JX;&p_1lK)%ya`_$(P*hgy9pB8&xFK)9I^hWTJ0Aq<+O5==iU~gb|4G^; zI&$jt)?YYUCJi@zfCgApka6{S$VrYd3srWWHXb&EPxP1a21Rp*qV;>t6TH_DuA&am zK+NQ9rt8rd>E6I|nuoM#r3-q*L2H8VmkJHV*OE)w_^Dv)hI-xm1HRll#+b7PO~s#C z^WHW3{kXUF*PQj(#T;+35DcI@H9DqR9+g^AUAy4R9O^x`O70g>CJqf)m5sA*0C|VUowLlZoJp6&s9qjrx`L7a1TBnMKm7jO_Yi#ewnF%DLcbAWkWq6 z#@YO3LXj3$?4-NssBoebXKuaxY!Uu*pOx{b!t7mSX!Ti-Obo|ZZB#|cw+GUB86v*d9q1M zqmb|Il9F)X&(RM6g`G2mum;+cRzPJXS8!kRIctUj%}^%SRI#odZKtnFh`zNn? z0;b5$)4VmD{YnIRT|bspLJn`JoHm-kCrX&+C%G_Q21 zbbxuR<4o`F;QVMzY}g24?JMg;cuinf?}nc-4&z82?hByL)2 z6h>@wq&UT~=R;gvYEVk4V36bzzlMB5gxmyM%J-dOJIyyQKY54I&RYWJKo~Tx=zIG`7obLO7jI zW_lvDyEL?<4zHTmW+;7cDoJRPIpxv=!K*{0svERkT)(Q0GlvFGmQ=H~4wv^NM^ zeNKK`eK1iuUt;3@i;#tG^zTAewDSHz_>xQ8eLy<$y3ITQehgs&`0Bgx--1<^fu&?d z^t~Sul+QkbX2geA8|%J#biFrO2jIs$@}#L1gWlqCYpb8S=6&%@TBT~`6IahA&R@0u z({!XLFpu_qU|_c1KIe+;Z>}RIh?#<`KX`2Ha|kR@Q9Z6oGT$`~e@?KA4o;<1v`h}x zJSAxf^Rp}VvS!yO_Y&%MI78I_QHy|Am5Or;!cb?GNyNUy-(%_#a*GWamt*1-mL``RYVTbPw_PgXr5 z!{k#Zi2XMV;s0ebJle%&6Y=pPeVe3Cs_M}QhowggBzueyol$vBb#=dnknQW%SbhI# z^T3#v-U9y$Wbz>3j|k$=1j|_j=5Jq72H-4ksQ1}_e2kwtBsn~={U)_TdSxrCRT$$> z+=`=1;Bdvl54z!>BPw^Zn1Dj0&lf5jZnp+0b;Y!{i3ao0I1i9ifgw-?Ot-PliEp3& zcV0yeJo8pI|4+&Uo)qp+W}(5X^n~F|(`5-2k4DHg>A!-Bzw;;6mN?%Y{6dw52_>Jd z2u0!)Tr*8~e(8nY4c^Cf5PycapWe5e?#;5BeULIqD8$+|5do_lXi$T|fX=^55IX~( z{&#MmlXN(sT9W9wGn4{oRY9F9FU!L+KGFe=b=G5`C=?RV4r(T7L_{Z6Ge{+{;zLFt zuCN~BDzA|ZNfUz?Q!wF2{EH`P;k@yuD(q_N5~U!m`9z(tZvk3>{-#NJwaEp8X`8iZ zESV$t{{zn8XB5Qbw;3pcU1;6qTRcq}AG38^hWbpprh77ZGw=1ZHWtjmi+l)108PMRdl2>H> zdP#f}UNb*bTuTZM5+%~N*9h;OBmYf+numrT`22U%&%K~K1WtZRVB2|FIWaYY!}r2% zY$w0oM%mOWwk`(Ubh*d)=Lh9-u1-G;2Xxj$skMH~&QDYT27f?QnixLWf~*!+vZUNO z?ly}`H0YGWb)C_dt%HVP_mr+)4;d$FWI^_>Dr;Yn3C-$J?f?yW;s?`k6-_Z&n z>G83&cRA%50`S_3x|jO#CR8^!lB4NwgU`t|@$1Z|elUZN^48|supe+tUD*S>eu;>3 z+9LRG#%Tx)fjtEy;#&<~xWPFeWa<}{vYQ2GOh>u#aj*=(lBUo6hM&mizFY>FYF@&h7h_;&*YqRJ6~Yh?+6+BLdK-E_mD2cFsp4VtPm?@5K}y zul)x|Yw#PJPT0T$JuOHjd@X_B&(`Z$hrkZ>VMfNM+E5-Q6jHR(ml1`tz3W*EK=2yJT|M&>$c|K zB)c|HwDW7v1D*nAe=sN?^)HMoIrg8hm#B0*r3^Iof(EvxCas{UxLXWi*I;(=xTj*K zKSFA$h;~|AkAjEyaOafrUGAVOm-AV~V^(h;RY8FOf{Pz!Y>eve-ZZeNifU@{;yF@sa;t7bwz;k@!T-xOvsp8t z*Ecx$FK6nAJYD}%lxB_X#yRi7UseXy5Cr=EIGjLVfa^cJ0HFNjx8CyQ-zbsM%%)%e zg%Zi}ZY6!6lG0?qEDqr}OnTuy1&U0EpzpJ0N5%T9_8#c1hCLmVc!P-uyf!Z`=4uVE z({7n48q^@=HcztrVX)hLy+;#m^lwM^_ir{+z{3ihsuUTNW-&tIpn_olM9;F>@SB+G(tLKgrtARxH5TVE$6w?Sf=LOcH<;; zi{oTpXrtIanFxnxT#`;NZOtD87`!P-tw~J3_E;?3qc%t1hufY;;Lpm7ixTnMGihHU z&UQb+)+YK-6wE})U}AqI0Jb@#%E^F^%t!AeY5cxFE&UFv?Y{pTqFQN z_FJfE1@L?ojjou}t=@W`l=_}MK!x^N6e_h;X2F)L>c24~#bJcCvzL0RV3V-v(0RjZV?C^gIa9kY@QB$J06U&? zZm78F=)_AB>fab0hqu$%a7A(1apKmczkR+>CDgepE0?+rJPUaRRdn*itBnc;7{%NI z4?4jJvDB$p4z`%Go9R4u>bB6%|Eb{6IuDj{IQQ>IN(uO&JIDF!M z&;E%a_Ul==-fJ~$%fgN-%1FPxD9cM10K`aiK`N?W0+wYg2GgDm*ysQ2rsNbq&N~Fs zCxgOz>?S1TRCV=kYD7(s1(ZubmI_|(w_mv6{6*8*yloZKV|w%!I;0&p4(12Hil)!n zfm}_eOngG)HQn(3v%3wDw%#f{8NXY+jC>@aC<6)IpQj?LO1$t!H!avBtaciU$?lke z*m6B1Uwrb`;tVg=5f%<$gUJ@$;P7`RY5|?wm^pMQnAC3%(jOdei{bCGN^nH}?)SCv z7K1kE@bGHUaU*U#*RpWVU!<_2CxCH)IG@St7PX9#x+VPJ@x zGoQ_@vyLGcNbxg6E3pMeWf2daIMfYM{f@Jb@{C;y|7oGEaAJcJ{O|O^jLQp&>uWjE z(}W#eCQuIJwx@qAY`G}~U~n~6Y0&yreIREzQp$vs_-!3E2{P-Rz0C#<9i*}Yoz@=? zqSE3SMn<}=gxSbgDm>jPwBVr*<# z-*zY@DHOJ^e8QtN`Q{rsFZkUC-VpuR-2KR%l%A^WNzuY>z-RtGXglW2H=!BZzYM=If>OQy$5;&Wi*Rw2_Pm#s**su9Xn{(4( z?4e;4zVQOP@IAw?P7Ek_`RKFtMxxHRp(;3S0;BN6s#t?uBcn*Fk6x@jSeJ1yyw^1AQ{|-n_R4?#`HXvT}CCc zgI7Awj3glIw&&uqta7UvPNfL+66aaB<_J)8$nVgfzrcNRv_L|=KDQ7My+?->=JNc# zXqW@GCBAvt>g?J`lfW1QM_UK;Oh3-cZ(Q)-Cq+E%K7T>TeTaE=@cg{b-R;fSS@6&| zc)(86YRJ00we@8X?uyM#3YLi8ef%YhEgtuWW@~6_7XH|D-Z`f&^|oIxC@LD=bbRvU zi9burNKM_jTqyU$f4MM_cW=eb8hU8Jfx~I9m)L3VKMFkO6d6P(9{k%QGL1rd)&BVY z&4tYdk$k9hw6!hSh)^GOfCv3ebA{?E!E^3JC_!0SSp?k(^Szu;)|wygH~iP(5cKxe zR=W4)<}ekOo5fm7G)>;BbezTU_y_*{IiyJNEAs>aO8Fre0AjaaV+0R-M_oLvm(Mhx ztEojev&wfC?~;MYOUvydw%5JB<25i5MH=!l<1n`u(oF0eRa#!S{Oc=yu{4uTKK(R9 zY`$xW`|P9u*CQ>Mp7C(b+^5(0g-WP_u1~!#kA0AWDA!fN)2ZO4uTSp9BeZNQtsu^= zdy8Y)OqcFu?HE}S#y=2cVHQTo%5zz|Kd(wKl@DA=KvkwNBBaw~~j_6`v4c z?4VjSOr_p$5{q$vaOvLOVZ!gY9%8YJksNnG7TkJh*8TY)NqR{&=^grQ`%jnFs>PTE zmmXSEA;-4N9-d(@Lq{||%Ag?@O4U!`0xVUYNGPkWC|OJRV9uCOA0pRX&rCTv_ZGeX zyr);;JoKeOV9I@{7+-xw{kpnzZ(Z+-0tlfL7<2FEd?AV;#dvA+HLs{v;AYflGn}S8 z<=Nvnt?D#;l$nE+aFR_6j2RkeVA3)Xcc?O7Z;gZpiyIc8di3_TbfoN!#xzx+VN9`7 zOO=<$Mr=(mdJ{L=KfD^6=(F6}TX62ym#xgvfUo;^NV{;dM=f{cL?aOzoO4?DSp_zM z6dyMJkcWimVvv43f;YH{feATgG%g)gjpg$)+K%(*8*gx5sWF@|Y<1y6uZQO07wG9c zH>|fZyTkz8<=fIes>W-b=*~{w!2@}#4ji$Dw z3Vbfjcd;^101&6}_nW^v{lXyH{wQM>NEMuu8=T3*!?pfX} zn5Ir60lodBE6u72TX~#=6v;|bnz^5;oqAS-%#IAH5mP79Q|l~llV{Zhu9ydn5(=xo zVuNwm#e4T5+iY&V`D_BFEGvrsxN&nd%zXFl<%$e8#0-~k?9$5~2h|(j^OVSTmr^;% zYWC9w=^VT08y7|7ww{tnVwa_P?(#@cw1+AXmHgWA8=C?fP#?sDGC&UF>rt1|@5)w^S_<*Bn>M%7KMGJ+MxG!_eragjo9ZO6`BIbWeD;H`gXg=EkI=p+ zOYkHx)_%5Ed9o;=uN;%BeBPYKKf};w!rl1oWS^e}Z69M8 z{qOKk7IWqplb5D_xm#Mqx_{(g%# zdu#gm?L!T77>h`U!}7!CD(^8tAUos^`Z)F8T(#Xp;`&vUO5r2lz>hoifuH=t@L?YG ziOFbmeb2VueKvVD5w_eVoS_wnBEv-h!&_nl+DTwi9^nz^p?{GY!w)=r3rLLDImcqHlKlc<#$GRf+^ za1ek!qo_}d$r^ENz%Q1nWJs*f6yN=}l_{qC`GMNk)u#Y=)bR{0$PzxsV0u5n~6re>e;_!kQ77BMK7mTq%rC%!l&Nj=$? zNGRo*YYRt3J@sv9SR&mEC^{x(T5$#d#GWD*pkcTRRe5J2<0@!}n?27D+6-nMRpfT= z31os)kTOiD< z7!$aKSn3WMe5%~WcAO*LZ8C>{zDu@s0QCVFiG)aeR2{ShPizQ zBi$s3;`a|TR$Sle4H1E)ufG4vRo1u6>$X*O0S1qMMeZ%aOFbH+2O1v&Ww2lE|3citS}2_K)Z( z4-C??3Kw5k^>ck}yQy-Q55-uhQ#!+Vrm9Sd-uKdB2TgEqjadX4_&>FS89VgPMi~YB z*~>tguwZLrV*?J>V60fJ&;&hR3yMSKjeHozAIDG0Yh5O+jKjdd;LJPwmSnLf@B?AN zzf}nVNa(?Wf2Ac{;fVk<2$cMOU#10E04ZH4e-4%fbDG<=_Pr%`f}!prj*X2GMM(rS zm4E}Uv@Hj0tid5W4+D%XENFw-nkx_f79o@_SDOpf)zx>Sj*ddb-lw_mehjm$QpuU} zJbu-mUnv5v+^1HeJBJT}Mu8dF_5@s$P}fGqxMh#4Ws=52SDxs)m09!VKIuOF*qVJF znBgHPC9?a?J?X;3dKl+(x^;Ug8tTT ztkGVBethn7O@9(vq_$JK;bhpbO7H`xs06$+@CE9u3n%~f`GgOV4%Jt5V{Xd)mpW%6 zc1xm@h3i|Rk(2@O0Ib1n~TIr=_8E*&V&r{zrdJXZBB+>i!M zN^xW_5s9b)ds44@zDTqlTe|qavJ?c>iK3OMpEY7WYCqbwUn7n-V?@o&r!q*?K3ib1 z_FCm5e)7oM6+CBRryTA@2(vU2tBu4BpF-4PbMI0`}E!>|pk8%VB8gH3#KE%qdW}5%fd#wke7L8SuO* zEPcMNMd-PjXOFNdQR4v>GD+0gxY~UXo%pIaS5vV6{h4du3!F?9O3@P+mT`Sa^tb9Cxu;oNzCu}HVRAMzXpq*+?eH+&N@}ST&T^WVU2T>Hev5= z4Z@}JpBI=RK3gi?5D~@cUAA@8I6a1CbgJ zKBQbF=3zPh6^_{g6&(&Cl{MU5uSCq``@UA=_mB#jOvrNO-fB0w%KF6fu)d!sL+6KW zw^dtYw(%rs#TSLW=s?#_YE-XddY@x#c-m(MC>8#Brq%egU#SbNz5S40^EqWaZMUI|$3g zrwNl|A0+!mu;aA0s|x$g^X#wyh^S-3*X9q{_WDVQXo;jcr_KBVYVa1e?)AzH-YD_n z{|CJylJD2f3ltp1A_Rtt;eZm*X7>#kjf9&KNP`}OX7x*2gW2h5|4qc<1eBf$2!&sT zROGRMW8U5@&YUd!kCxT2*K%He!wNIdVf&)5km+gZk2HcQk`+#$iitpYMqQhiVj$Nu zoL{UMMT*Z;;u3ehNnpyb&m;94MJFpu!dB+VotaD%z>a*9`Vg$I0Dq#ab5p<${gj>~ z`4iq()NRBK;%M+4oQUd01b#Woc~fD2kQJIdSt&cTuR7Byvk3EQr|T8^DY!n%Dl}uRzf{HOQ$qhs4Z(hoqRK@jI?#Qr zy#+@En3efwOwTaMsvx;O4_CX5x+ax~Ym~v}iPm4f#;z2mPwrH$dhZAo%W5>?a4k+KQJXuS(qg(NWyV_5`sq<#KR`ZG0Y+jYWaVA*(7VhxT=Max;!N81uma zy)p#({1ni7T01|MIXN4&D&kCKmaiqJhDsLUV2HOPcl0;-#q2C+xqZkpwezTwf_!%`Nz{&ZSPTPARc`X5gwT zfhjU3x%618DVJV;QUm;q>QDa0Ik^lS2wAP)Y6C|QNoC21i${`n4{8|wYHfL&Kv{uR zCi`IL0@L^aqw$wy>ei^-u<`pu5f!ji-katD*%ljCr$Mh%CQftij`E6<^khO^rc~lc zwoJPzMO|Jg{#`#|uMOBCsj3ht)zKC1TKjY&>3Odz6Yc2~+Lv8TINzL21`TS>WZS;l z(@Vj;s@tEKvK^`UEV6^z45X$|hq+Y;LcVGtpB65YG<#<3O`daIoAZ`QtM{*Wc- z8fMPZCS<&=EMq4X7_2}sTKv*E+vwPYfXu}f|6M5g+cD&t#iVQqfS65$ms*a1pdYLk}Iih=&+nRA- z5M?$D3NjUv1I9nSG_#Mi(tCd&1{I^;C62**&NoH9X2gU|iRAao9mb>1&d{zSo2Y*A zdp%c4t%LC(+T8j}>C}`CIN^+y0PG6liRi6n+McZ$H)4H^hStU^ai&pE$F_RUZnaxg zaBw-lm4E%Z15QqSB0w3NXEt|fVWRU@EuYg+VA65p%K*4~?{fa^;E4au@EP#A2=7Vi zHRB2E*2|WEWB<|PzQwdFi0z`_uoHeKtS{4wN%&ZyM6nEV{~#@SMXVy-IM~VB^|FbT z{)u$gea6FyOYNvWZ*%uY?_bBq$EpTiwfkJpX+LH#`-HzR!0E5&mH%Rr*O#jfh2#@jYUDYp0Ucq+fAZ|4Te8_GBBdr)d!G^Z>p62tf?lACc%t0N>)d^L zZ!txch|gs-+I{jD^(jV)RkSMwYP;6+q#m!f7h7!Kw%qj-fwi#~W1_B4(lA7ZotEQk zIlbnSC4>3uF@Ed~w*C!jCNI<9wYvQjU-Pi7Gtde~=f(2Gr3 z*#ua++voLg(0WcvY@GdlmV*t9`d3YE3GL5_2#+&BEz{o`5v|tkb$}0m&ZwHy5VKz<4?{q(ExLlG=Ee`K;S2N*71n3aeJW*au zU|j~>4<49B(Gm%zZ9nsI0!u+s9Sqi!nvxWcqa<hZ-Ohy4*0oZFApZ zPvordM0iNF3AVaPKEa^GQeFf9p}L0W7~x;`OQjidzxS-iv&dg<>BPSLGLJ7gxZv8P zS-d~S4Aw{>*gslu^gS`~=rY|(Zdj~+5@VH1iwI~6;raTbHH0I;X9G+is@B;0qbi7H zk;8ruH-;(yx0FY3t%W16)T;~KK%Pm@%Hkq4kNUw-#6PEUCDn!BgUZAp9! z`EJmIBY|uX6BR#+*usOp%~C>tNzb*LP zyxd=m*ws9L_nUy8ijw7Q*1C3R?m`o0hz*pm^X!%?76uuYXsu!N<2gc9vd!=??&{@w?f7b zDA6;BKcTE$)L7J#!~2RosgLQ$r(r|{BFLb8n~2vun7~s6D(9>pd$7kRcW^$7CkoLw z1?-r)CqN@j`eBr#yJdmz8>O(cEGbt@BWitq;YqDvxzmq3J^?b?PsY$s9*Tk%jDm6= zc44;wwSSBY?+c>KENz^LTnmY>2Hc*AT`-hj^ZocmzAQa~#_MVa&u$bN+0;;feb<-zDvN|W zr&Grc$0F)|4x)!N%A7Z5m{(v5hN4DnK_XfSk|5MZlqQxO#)?I6h5KbdMN^P)d&Buf zC_|<>Y6WXM9-H~I7MZM7M>YpKWnlO@xZEZnfA%m=Mc8sDtN7aaSN*d)KmI%X^LxZ0 z`?6moEJzhl=|eTt?m8FI%}aH_X#FBQv1>FZon!a9zTa|3$gy?E-fW{s#c3?O9xPz>7U<=`e}_@)5Y-ZpCU(90S_5db?2YDXyU3XlE_CD6TTK7h0Np?z07hGk5pe> z6H*5z`uFhO-91z;jv#b)5FDLgkfkuPI^^fJcn)$_9I;8z=kaR{-K5%WvrOERo!>t? z$b7EqFLzLPKyWiQGV;>|W(xzIH(L?8>*jm{!NGkT$e6v@Ztj;&tD&K>;Z#k+*F64H zm_tonJ!g1bMn9ol`$sn26uySKWK`nR|lgpmI{ zOUFu3R!9c75=*$b;Sz#mndb0hA))v~MBv1GzgyBj$8;;l=j3&@5h`|Zc1FPR5R(6T z88?*nRFK*V_g#tvoUf?lS8w{1sdkyRnEz{r$<_o}yj|i@nY>rVEm{;L?55gha?e4B z*No(2yvYaR9;v;WrTB94>iJnyk~Kj#UL;b?7IT7F`k`+3LLYq4z%5nX?>MB?mOAek{lxy-1^P^uN2zd4dSV@3|I}HdbvLU zMr7;p@rGu~do63nM(t=zHX#)u?;53krT%c6j#bi^!Cq9kM@1ETd^hFrgH7Z&2BjTLB`{?DR{$$q65ws>btL z*VbK#Y~pJ`N~-IvtQ7rcH#UrH@Cgb@*Jpcer-fGvj^GxEQHy<_;OuJNVx?@r02@U| zV!3G>a>fJrlf0|Ul-q}F<|SBI|CLPgN|3#4X&I9u^6qjEmgFTRS-l5(uo?=qNKce&UAN$ z*M}t#5<&YTw|_7dgLG2hzkw?I0X$%QW4dW|5S^lxs-k*=L6Lpl{RqHt_xUgdCY$0n z@x#9~cSc1k882LoG&_qXZ?sUCrEckn;cQi-Zp5MV+ck`TO)kVQa7ZFf6AHVUZkPBw zsl-=yrc4ccG3=CLo)eQG4(B`nh?D`&_v=IZl|$<$y!Hvo-`20R)+f&9yF_r%RuoHv zsRyByk%8Sh;tbzL5*6)yS?@MYWhsvM;1SMd=ivXFfJiY*=vc;;u<(3EcW#yPek*NhCfp2 zJY(#i#FdA%lj14hQ9f{E!4YijR7>atb7>i{04@-)H-p}L;OS{Aw&IM>?f+Df;xsXU z@t?V-VJ%Ap@s9-sGI_&^2?@x}M+SLwzy2;7Nhby8IA89h8#$QW{p}P^#2>|#Gr9&?SdT70GgjVtEAnPL(cTb!Gq{S7)zKR(6{|70v%cB4Q literal 0 HcmV?d00001 diff --git a/src/app.rs b/src/app.rs index 6ccc893..87f6873 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,32 @@ use clap::{self, App, AppSettings, Arg}; -fn add_common_options(app: App) -> App { +fn add_input(app: App) -> App { + app.arg( + Arg::new("input") + .about("Input file") + .default_value("-") + .long_about("If not present or a single dash, standard input will be used"), + ) +} + +fn add_min_max(app: App) -> App { + app.arg( + Arg::new("max") + .long("max") + .short('M') + .about("Filter out values bigger than this") + .takes_value(true), + ) + .arg( + Arg::new("min") + .long("min") + .short('m') + .about("Filter out values smaller than this") + .takes_value(true), + ) +} + +fn add_regex(app: App) -> App { const LONG_RE_ABOUT: &str = "\ A regular expression used for capturing the values to be plotted inside input lines. @@ -15,22 +41,18 @@ Examples of regex are ' 200 \\d+ ([0-9.]+)' (where there is one anonymous captur group) and 'a(a)? (?P[0-9.]+)' (where there are two capture groups, and the named one will be used). "; - app.arg( - Arg::new("max") - .long("max") - .short('M') - .about("Filter out values bigger than this") - .takes_value(true), - ) - .arg( - Arg::new("min") - .long("min") - .short('m') - .about("Filter out values smaller than this") + Arg::new("regex") + .long("regex") + .short('R') + .about("Use a regex to capture input values") + .long_about(LONG_RE_ABOUT) .takes_value(true), ) - .arg( +} + +fn add_width(app: App) -> App { + app.arg( Arg::new("width") .long("width") .short('w') @@ -38,37 +60,25 @@ the named one will be used). .default_value("110") .takes_value(true), ) - .arg( - Arg::new("regex") - .long("regex") - .short('R') - .about("Use a regex to capture input values") - .long_about(LONG_RE_ABOUT) +} + +fn add_intervals(app: App) -> App { + app.arg( + Arg::new("intervals") + .long("intervals") + .short('i') + .about("Use no more than this amount of buckets to classify data") + .default_value("20") .takes_value(true), ) - .arg( - Arg::new("input") - .about("Input file") - .default_value("-") - .long_about("If not present or a single dash, standard input will be used"), - ) } pub fn get_app() -> App<'static> { let mut hist = App::new("hist") .version(clap::crate_version!()) .setting(AppSettings::ColoredHelp) - .about("Plot an histogram from input values") - .arg( - Arg::new("intervals") - .long("intervals") - .short('i') - .about("Use no more than this amount of buckets to classify data") - .default_value("20") - .takes_value(true), - ); - - hist = add_common_options(hist); + .about("Plot an histogram from input values"); + hist = add_input(add_regex(add_width(add_min_max(add_intervals(hist))))); let mut plot = App::new("plot") .version(clap::crate_version!()) @@ -82,34 +92,33 @@ pub fn get_app() -> App<'static> { .default_value("40") .takes_value(true), ); - plot = add_common_options(plot); + plot = add_input(add_regex(add_width(add_min_max(plot)))); - let matches = App::new("matches") + let mut matches = App::new("matches") .version(clap::crate_version!()) .setting(AppSettings::ColoredHelp) .setting(AppSettings::AllowMissingPositional) - .about("Plot barchar with counts of occurences of matches params") + .about("Plot barchar with counts of occurences of matches params"); + matches = add_input(add_width(matches)).arg( + Arg::new("match") + .about("Count maches for those strings") + .required(true) + .takes_value(true) + .multiple(true), + ); + + let mut timehist = App::new("timehist") + .version(clap::crate_version!()) + .setting(AppSettings::ColoredHelp) + .about("Plot histogram with amount of matches over time") .arg( - Arg::new("width") - .long("width") - .short('w') - .about("Use this many characters as terminal width") - .default_value("110") + Arg::new("format") + .long("format") + .short('f') + .about("Use this string formatting") .takes_value(true), - ) - .arg( - Arg::new("input") - .about("Input file") - .required(true) - .long_about("If not present or a single dash, standard input will be used"), - ) - .arg( - Arg::new("match") - .about("Count maches for those strings") - .required(true) - .takes_value(true) - .multiple(true), ); + timehist = add_input(add_width(add_regex(add_intervals(timehist)))); App::new("lowcharts") .author(clap::crate_authors!()) @@ -136,6 +145,7 @@ pub fn get_app() -> App<'static> { .subcommand(hist) .subcommand(plot) .subcommand(matches) + .subcommand(timehist) } #[cfg(test)] @@ -183,4 +193,32 @@ mod tests { assert!(false, "Subcommand `plot` not detected"); } } + + #[test] + fn matches_subcommand_arg_parsing() { + let arg_vec = vec!["lowcharts", "matches", "-", "A", "B", "C"]; + let m = get_app().get_matches_from(arg_vec); + if let Some(sub_m) = m.subcommand_matches("matches") { + assert_eq!("-", sub_m.value_of("input").unwrap()); + assert_eq!( + // vec![String::from("A"), String::from("B"), String::from("C")], + vec!["A", "B", "C"], + sub_m.values_of("match").unwrap().collect::>() + ); + } else { + assert!(false, "Subcommand `matches` not detected"); + } + } + + #[test] + fn timehist_subcommand_arg_parsing() { + let arg_vec = vec!["lowcharts", "timehist", "--regex", "foo", "some"]; + let m = get_app().get_matches_from(arg_vec); + if let Some(sub_m) = m.subcommand_matches("timehist") { + assert_eq!("some", sub_m.value_of("input").unwrap()); + assert_eq!("foo", sub_m.value_of("regex").unwrap()); + } else { + assert!(false, "Subcommand `timehist` not detected"); + } + } } diff --git a/src/dateparser.rs b/src/dateparser.rs new file mode 100644 index 0000000..e33bc2d --- /dev/null +++ b/src/dateparser.rs @@ -0,0 +1,367 @@ +use std::ops::Range; + +use chrono::{DateTime, FixedOffset, Local, NaiveDateTime, NaiveTime, ParseError, TimeZone, Utc}; +use regex::Regex; + +type DateParsingFun = fn(s: &str) -> Result, ParseError>; + +// Those are some date formats that are common for my personal (and biased) +// experience. So, there is logic to detect and parse them. +const PARSE_SPECIFIERS: &[&str] = &[ + "%Y-%m-%d %H:%M:%S,%3f", // python %(asctime)s + "%Y-%m-%d %H:%M:%S", + "%Y/%m/%d %H:%M:%S", // Seen in some nginx logs + "%d-%b-%Y::%H:%M:%S", // Seen in rabbitmq logs + "%H:%M:%S", // strace -t + "%H:%M:%S.%6f", // strace -tt (-ttt generates timestamps) +]; + +// Max length that a timestamp can have +const MAX_LEN: usize = 28; + +pub struct LogDateParser<'a> { + range: Range, + parser: Option, + ts_format: Option<&'a str>, +} + +impl<'a> LogDateParser<'a> { + pub fn new_with_guess(log_line: &str) -> Result, String> { + if let Some(x) = Self::from_brackets(log_line) { + Ok(x) + } else if let Some(x) = Self::from_heuristic(log_line) { + Ok(x) + } else { + Err(format!("Could not parse a timestamp in {}", log_line)) + } + } + + pub fn new_with_format( + log_line: &str, + format_string: &'a str, + ) -> Result, String> { + // We look for where the timestamp is in logs using a brute force + // approach with 1st log line, but capping the max length we scan for + for i in 0..log_line.len() { + for j in (i..(i + (MAX_LEN * 2)).min(log_line.len() + 1)).rev() { + if NaiveDateTime::parse_from_str(&log_line[i..j], format_string).is_ok() { + // I would like to capture ts_format in a closure and assign + // it to parser, but I cannot coerce a capturing closure to + // a typed fn. I still need to learn the idiomatic way of + // dealing with this. + return Ok(LogDateParser { + range: i..j, + parser: None, + ts_format: Some(format_string), + }); + } + } + } + Err(format!( + "Could locate a '{}' timestamp in '{}'", + format_string, log_line + )) + } + + pub fn parse(&self, s: &str) -> Result, ParseError> { + let range = self.range.start.min(s.len())..self.range.end.min(s.len()); + match self.parser { + Some(p) => p(&s[range]), + None => match NaiveDateTime::parse_from_str(&s[range], self.ts_format.unwrap()) { + Ok(naive) => { + let date_time: DateTime = Local.from_local_datetime(&naive).unwrap(); + Ok(date_time.with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))) + } + Err(err) => Err(err), + }, + } + } + + fn guess_parser(s: &str) -> Option { + if DateTime::parse_from_rfc3339(s).is_ok() { + Some(DateTime::parse_from_rfc3339) + } else if DateTime::parse_from_rfc2822(s).is_ok() { + Some(DateTime::parse_from_rfc2822) + } else if Self::looks_like_timestamp(&s) { + Some(|string: &str| { + let dot = match string.find('.') { + Some(x) => x, + None => string.len(), + }; + let nanosecs = if dot < string.len() { + let missing_zeros = (10 + dot - string.len()) as u32; + match string[dot + 1..].parse::() { + Ok(x) => x * 10_u32.pow(missing_zeros), + _ => 0, + } + } else { + 0 + }; + match string[..dot].parse::() { + Ok(secs) => { + let naive = NaiveDateTime::from_timestamp(secs, nanosecs); + let date_time: DateTime = Local.from_local_datetime(&naive).unwrap(); + Ok(date_time.with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))) + } + Err(_) => DateTime::parse_from_rfc3339(""), + } + }) + } else if NaiveDateTime::parse_from_str(s, PARSE_SPECIFIERS[0]).is_ok() { + // TODO: All of this stuff below should be rewritten using macros. + // Reason for "repeating myself" is that I cannot coerce closures to + // fn types if they capture variables (an index to PARSE_SPECIFIERS, + // for instance). + Some( + |string: &str| match NaiveDateTime::parse_from_str(string, PARSE_SPECIFIERS[0]) { + Ok(naive) => { + let date_time: DateTime = Local.from_local_datetime(&naive).unwrap(); + Ok(date_time.with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))) + } + Err(err) => Err(err), + }, + ) + } else if NaiveDateTime::parse_from_str(s, PARSE_SPECIFIERS[1]).is_ok() { + Some( + |string: &str| match NaiveDateTime::parse_from_str(string, PARSE_SPECIFIERS[1]) { + Ok(naive) => { + let date_time: DateTime = Local.from_local_datetime(&naive).unwrap(); + Ok(date_time.with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))) + } + Err(err) => Err(err), + }, + ) + } else if NaiveDateTime::parse_from_str(s, PARSE_SPECIFIERS[2]).is_ok() { + Some( + |string: &str| match NaiveDateTime::parse_from_str(string, PARSE_SPECIFIERS[2]) { + Ok(naive) => { + let date_time: DateTime = Local.from_local_datetime(&naive).unwrap(); + Ok(date_time.with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))) + } + Err(err) => Err(err), + }, + ) + } else if NaiveDateTime::parse_from_str(s, PARSE_SPECIFIERS[3]).is_ok() { + Some( + |string: &str| match NaiveDateTime::parse_from_str(string, PARSE_SPECIFIERS[3]) { + Ok(naive) => { + let date_time: DateTime = Local.from_local_datetime(&naive).unwrap(); + Ok(date_time.with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))) + } + Err(err) => Err(err), + }, + ) + } else if NaiveTime::parse_from_str(s, PARSE_SPECIFIERS[4]).is_ok() { + Some( + |string: &str| match NaiveTime::parse_from_str(string, PARSE_SPECIFIERS[4]) { + Ok(naive_time) => Ok(Utc::today() + .and_time(naive_time) + .unwrap() + .with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))), + Err(err) => Err(err), + }, + ) + } else if NaiveTime::parse_from_str(s, PARSE_SPECIFIERS[5]).is_ok() { + Some( + |string: &str| match NaiveTime::parse_from_str(string, PARSE_SPECIFIERS[5]) { + Ok(naive_time) => Ok(Utc::today() + .and_time(naive_time) + .unwrap() + .with_timezone(&TimeZone::from_offset(&FixedOffset::west(0)))), + Err(err) => Err(err), + }, + ) + } else { + None + } + } + + fn from_brackets(s: &str) -> Option { + match s.chars().next() { + Some('[') => { + if let Some(x) = s.find(']') { + match Self::guess_parser(&s[1..x]) { + Some(parser) => Some(LogDateParser { + range: 1..x, + parser: Some(parser), + ts_format: None, + }), + _ => None, + } + } else { + None + } + } + _ => None, + } + } + + fn from_heuristic(s: &str) -> Option { + // First we locate the first digit + for (i, c) in s.chars().enumerate() { + if c.is_digit(10) { + for j in (i..(i + MAX_LEN).min(s.len() + 1)).rev() { + if let Some(parser) = Self::guess_parser(&s[i..j]) { + return Some(LogDateParser { + range: i..j, + parser: Some(parser), + ts_format: None, + }); + } + } + break; + } + } + None + } + + // Returns true if string looks like a unix-like timestamp of arbitrary + // precision + fn looks_like_timestamp(s: &str) -> bool { + Regex::new(r"^[0-9]{10}(\.[0-9]{1,9})?$") + .unwrap() + .is_match(s) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_rfc3339_brackets() { + let r = LogDateParser::new_with_guess("[1996-12-19T16:39:57-08:00] foobar").unwrap(); + assert_eq!( + r.parse("[2096-11-19T16:39:57-08:00]"), + DateTime::parse_from_rfc3339("2096-11-19T16:39:57-08:00") + ); + } + + #[test] + fn test_rfc3339_no_brackets() { + let r = LogDateParser::new_with_guess("2021-04-25T16:57:15.337Z foobar").unwrap(); + assert_eq!( + r.parse("2031-04-25T16:57:15.337Z"), + DateTime::parse_from_rfc3339("2031-04-25T16:57:15.337Z") + ); + } + + #[test] + fn test_rfc2822() { + let r = LogDateParser::new_with_guess("12 Jul 2003 10:52:37 +0200 foobar").unwrap(); + assert_eq!( + r.parse("22 Jun 2003 10:52:37 +0500"), + DateTime::parse_from_rfc2822("22 Jun 2003 10:52:37 +0500") + ); + } + + #[test] + fn test_bad_bracket() { + let r = LogDateParser::new_with_guess("[12 Jul 2003 10:52:37 +0200 foobar").unwrap(); + assert_eq!( + r.parse("[22 Jun 2003 10:52:37 +0500"), + DateTime::parse_from_rfc2822("22 Jun 2003 10:52:37 +0500") + ); + } + + #[test] + fn test_prefix() { + let r = LogDateParser::new_with_guess("foobar 1996-12-19T16:39:57-08:00 foobar").unwrap(); + assert_eq!( + r.parse("foobar 2096-11-19T16:39:57-08:00"), + DateTime::parse_from_rfc3339("2096-11-19T16:39:57-08:00") + ); + } + + #[test] + fn test_bad_format() { + assert!(LogDateParser::new_with_guess("996-12-19T16:39:57-08:00 foobar").is_err()); + } + + #[test] + fn test_short_line() { + assert!(LogDateParser::new_with_guess("9").is_err()); + } + + #[test] + fn test_empty_line() { + assert!(LogDateParser::new_with_guess("").is_err()); + } + + #[test] + #[ignore] // need to make code LocalTime agnostic + fn test_timestamps() { + let r = LogDateParser::new_with_guess("ts 1619688527.018165").unwrap(); + assert_eq!( + r.parse("ts 1619655527.888165"), + DateTime::parse_from_rfc3339("2021-04-28T22:18:47.888165+00:00") + ); + let r = LogDateParser::new_with_guess("1619688527.123").unwrap(); + assert_eq!( + r.parse("1619655527.123"), + DateTime::parse_from_rfc3339("2021-04-28T22:18:47.123+00:00") + ); + } + + #[test] + #[ignore] // need to make code LocalTime agnostic + fn test_known_formats() { + let r = LogDateParser::new_with_guess("2021-04-28 06:25:24,321").unwrap(); + assert_eq!( + r.parse("2021-04-28 06:25:24,321"), + DateTime::parse_from_rfc3339("2021-04-28T04:25:24.321+00:00") + ); + let r = LogDateParser::new_with_guess("2021-04-28 06:25:24").unwrap(); + assert_eq!( + r.parse("2021-04-28 06:25:24"), + DateTime::parse_from_rfc3339("2021-04-28T04:25:24+00:00") + ); + let r = LogDateParser::new_with_guess("28-Apr-2021::12:10:42").unwrap(); + assert_eq!( + r.parse("28-Apr-2021::12:10:42"), + DateTime::parse_from_rfc3339("2021-04-28T10:10:42+00:00") + ); + let r = LogDateParser::new_with_guess("2019/12/19 05:01:02").unwrap(); + assert_eq!( + r.parse("2019/12/19 05:01:02"), + DateTime::parse_from_rfc3339("2019-12-19T04:01:02+00:00") + ); + let r = LogDateParser::new_with_guess("11:29:13.120535").unwrap(); + let now_as_date = format!("{}", Utc::today()); + assert_eq!( + r.parse("11:29:13.120535"), + DateTime::parse_from_rfc3339(&format!( + "{}{}", + &now_as_date[..10], + "T11:29:13.120535+00:00" + )) + ); + let r = LogDateParser::new_with_guess("11:29:13").unwrap(); + assert_eq!( + r.parse("11:29:13.120535"), + DateTime::parse_from_rfc3339(&format!("{}{}", &now_as_date[..10], "T11:29:13+00:00")) + ); + } + + #[test] + fn test_tricky_line() { + let r = LogDateParser::new_with_guess("[1996-12-19T16:39:57-08:00] foobar").unwrap(); + assert!(r.parse("nothing").is_err()); + } + + #[test] + #[ignore] // need to make code LocalTime agnostic + fn test_custom_format() { + assert!(LogDateParser::new_with_format( + "[1996-12-19T16:39:57-08:00] foobar", + "%Y-%m-%d %H:%M:%S" + ) + .is_err()); + let r = LogDateParser::new_with_format("[1996-12-19 16-39-57] foobar", "%Y-%m-%d %H-%M-%S") + .unwrap(); + assert_eq!( + r.parse("[2096-11-19 04-25-24]"), + DateTime::parse_from_rfc3339("2096-11-19T03:25:24+00:00") + ); + } +} diff --git a/src/main.rs b/src/main.rs index 5095452..74689a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,11 +10,13 @@ use yansi::Paint; extern crate derive_builder; mod app; +mod dateparser; mod histogram; mod matchbar; mod plot; mod reader; mod stats; +mod timehist; fn disable_color_if_needed(option: &str) { match option { @@ -107,6 +109,35 @@ fn matchbar(matches: &ArgMatches) { ); } +fn timehist(matches: &ArgMatches) { + let mut builder = reader::TimeReaderBuilder::default(); + if let Some(string) = matches.value_of("regex") { + match Regex::new(&string) { + Ok(re) => { + builder.regex(re); + } + _ => { + eprintln!("[{}]: Failed to parse regex {}", Red.paint("ERROR"), string); + std::process::exit(1); + } + }; + } + if let Some(as_str) = matches.value_of("format") { + builder.ts_format(as_str.to_string()); + } + let width = matches.value_of_t("width").unwrap(); + let reader = builder.build().unwrap(); + let vec = reader.read(matches.value_of("input").unwrap()); + if vec.len() <= 1 { + eprintln!("[{}] Not enough data to process", Yellow.paint("WARN")); + std::process::exit(0); + } + let mut timehist = timehist::TimeHistogram::new(matches.value_of_t("intervals").unwrap(), &vec); + timehist.load(&vec); + + print!("{:width$}", timehist, width = width); +} + fn main() { let matches = app::get_app().get_matches(); let verbose = matches.is_present("verbose"); @@ -123,6 +154,9 @@ fn main() { Some(("matches", subcommand_matches)) => { matchbar(subcommand_matches); } + Some(("timehist", subcommand_matches)) => { + timehist(subcommand_matches); + } _ => unreachable!("Invalid subcommand"), }; } diff --git a/src/reader.rs b/src/reader.rs index e004999..094fd9b 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -2,9 +2,11 @@ use std::fs::File; use std::io::{self, BufRead, BufReader}; use std::ops::Range; +use chrono::{DateTime, FixedOffset}; use regex::Regex; use yansi::Color::{Magenta, Red}; +use crate::dateparser::LogDateParser; use crate::matchbar::{MatchBar, MatchBarRow}; #[derive(Debug, Default, Builder)] @@ -104,6 +106,73 @@ impl DataReader { } } +#[derive(Default, Builder)] +pub struct TimeReader { + #[builder(setter(strip_option), default)] + regex: Option, + #[builder(setter(strip_option), default)] + ts_format: Option, +} + +impl TimeReader { + pub fn read(&self, path: &str) -> Vec> { + let mut vec: Vec> = Vec::new(); + let mut iterator = open_file(path).lines(); + let first_line = match iterator.next() { + Some(Ok(as_string)) => as_string, + Some(Err(error)) => { + eprintln!("[{}]: {}", Red.paint("ERROR"), error); + return vec; + } + _ => return vec, + }; + let parser = match &self.ts_format { + Some(ts_format) => match LogDateParser::new_with_format(&first_line, &ts_format) { + Ok(p) => p, + Err(error) => { + eprintln!( + "[{}]: Could not figure out parsing strategy: {}", + Red.paint("ERROR"), + error + ); + return vec; + } + }, + None => match LogDateParser::new_with_guess(&first_line) { + Ok(p) => p, + Err(error) => { + eprintln!( + "[{}]: Could not figure out parsing strategy: {}", + Red.paint("ERROR"), + error + ); + return vec; + } + }, + }; + if let Ok(x) = parser.parse(&first_line) { + vec.push(x); + } + for line in iterator { + match line { + Ok(string) => { + if let Ok(x) = parser.parse(&string) { + if let Some(re) = &self.regex { + if re.is_match(&string) { + vec.push(x); + } + } else { + vec.push(x); + } + } + } + Err(error) => eprintln!("[{}]: {}", Red.paint("ERROR"), error), + } + } + vec + } +} + fn open_file(path: &str) -> Box { match path { "-" => Box::new(BufReader::new(io::stdin())), @@ -238,4 +307,59 @@ mod tests { Err(_) => assert!(false, "Could not create temp file"), } } + + #[test] + fn time_reader_guessing_with_regex() { + let mut builder = TimeReaderBuilder::default(); + builder.regex(Regex::new("f.o").unwrap()); + let reader = builder.build().unwrap(); + match NamedTempFile::new() { + Ok(ref mut file) => { + writeln!(file, "[2021-04-15T06:25:31+00:00] foobar").unwrap(); + writeln!(file, "[2021-04-15T06:26:31+00:00] bar").unwrap(); + writeln!(file, "[2021-04-15T06:27:31+00:00] foobar").unwrap(); + writeln!(file, "[2021-04-15T06:28:31+00:00] foobar").unwrap(); + writeln!(file, "none").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 3); + assert_eq!( + ts[0], + DateTime::parse_from_rfc3339("2021-04-15T06:25:31+00:00").unwrap() + ); + assert_eq!( + ts[2], + DateTime::parse_from_rfc3339("2021-04-15T06:28:31+00:00").unwrap() + ); + } + Err(_) => assert!(false, "Could not create temp file"), + } + } + + #[test] + #[ignore] // need to make code LocalTime agnostic + fn time_reader_with_format() { + let mut builder = TimeReaderBuilder::default(); + builder.ts_format(String::from("%Y_%m_%d %H:%M")); + let reader = builder.build().unwrap(); + match NamedTempFile::new() { + Ok(ref mut file) => { + writeln!(file, "_2021_04_15 06:25] foobar").unwrap(); + writeln!(file, "_2021_04_15 06:26] bar").unwrap(); + writeln!(file, "_2021_04_15 06:27] foobar").unwrap(); + writeln!(file, "_2021_04_15 06:28] foobar").unwrap(); + writeln!(file, "none").unwrap(); + let ts = reader.read(file.path().to_str().unwrap()); + assert_eq!(ts.len(), 4); + assert_eq!( + ts[0], + DateTime::parse_from_rfc3339("2021-04-15T04:25:00+00:00").unwrap() + ); + assert_eq!( + ts[3], + DateTime::parse_from_rfc3339("2021-04-15T04:28:00+00:00").unwrap() + ); + } + Err(_) => assert!(false, "Could not create temp file"), + } + } } diff --git a/src/timehist.rs b/src/timehist.rs new file mode 100644 index 0000000..45b4271 --- /dev/null +++ b/src/timehist.rs @@ -0,0 +1,172 @@ +use std::fmt; + +use chrono::{DateTime, Duration, FixedOffset}; +use yansi::Color::{Blue, Green, Red}; + +#[derive(Debug)] +struct TimeBucket { + start: DateTime, + count: usize, +} + +// TODO: use trait for Bucket and TimeBucket +impl TimeBucket { + fn new(start: DateTime) -> TimeBucket { + TimeBucket { start, count: 0 } + } + + fn inc(&mut self) { + self.count += 1; + } +} + +#[derive(Debug)] +pub struct TimeHistogram { + vec: Vec, + min: DateTime, + max: DateTime, + step: Duration, + top: usize, + last: usize, + nanos: u64, +} + +// TODO: use trait for Histogram and TimeHistogram +impl TimeHistogram { + pub fn new(size: usize, ts: &[DateTime]) -> TimeHistogram { + let mut vec = Vec::::with_capacity(size); + let min = ts.iter().min().unwrap().clone(); + let max = ts.iter().max().unwrap().clone(); + let step = max - min; + let inc = step / size as i32; + for i in 0..size { + vec.push(TimeBucket::new(min + (inc * i as i32))); + } + TimeHistogram { + vec, + min, + max, + step, + top: 0, + last: size - 1, + nanos: (max - min).num_microseconds().unwrap() as u64, + } + } + + pub fn load(&mut self, vec: &[DateTime]) { + for x in vec { + self.add(*x); + } + } + + pub fn add(&mut self, ts: DateTime) { + if let Some(slot) = self.find_slot(ts) { + self.vec[slot].inc(); + self.top = self.top.max(self.vec[slot].count); + } + } + + fn find_slot(&self, ts: DateTime) -> Option { + if ts < self.min || ts > self.max { + None + } else { + let x = (ts - self.min).num_microseconds().unwrap() as u64; + Some(((x * self.vec.len() as u64 / self.nanos) as usize).min(self.last)) + } + } + + fn date_fmt_string(&self) -> &str { + match self.step.num_seconds() { + x if x > 86400 => "%Y-%m-%d %H:%M:%S", + x if x > 300 => "%H:%M:%S", + x if x > 1 => "%H:%M:%S%.3f", + _ => "%H:%M:%S%.6f", + } + } +} + +impl fmt::Display for TimeHistogram { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let width = f.width().unwrap_or(100); + let divisor = 1.max(self.top / width); + let width_count = format!("{}", self.top).len(); + writeln!( + f, + "Matches: {}.", + Blue.paint(format!( + "{}", + self.vec.iter().map(|r| r.count).sum::() + )), + )?; + writeln!( + f, + "Each {} represents a count of {}", + Red.paint("∎"), + Blue.paint(divisor.to_string()), + )?; + let fmt = self.date_fmt_string(); + for row in self.vec.iter() { + // println!("ROW"); + // println!("COUNT {}", row.count); + // println!("WIDTH {}", row.count / divisor); + // println!("WIDTH2 {:A>::new(); + vec.push(DateTime::parse_from_rfc3339("2021-04-15T04:25:00+00:00").unwrap()); + vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap()); + vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap()); + vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00+00:00").unwrap()); + vec.push(DateTime::parse_from_rfc3339("2023-04-15T04:25:00+00:00").unwrap()); + let mut th = TimeHistogram::new(3, &vec); + th.load(&vec); + println!("{}", th); + let display = format!("{}", th); + assert!(display.contains("Matches: 5")); + assert!(display.contains("represents a count of 1")); + assert!(display.contains("[2021-04-15 04:25:00] [1] ∎\n")); + assert!(display.contains("[2021-12-14 12:25:00] [3] ∎∎∎\n")); + assert!(display.contains("[2022-08-14 20:25:00] [1] ∎\n")); + } + + #[test] + fn test_small_time_interval() { + Paint::disable(); + let mut vec = Vec::>::new(); + vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.001+00:00").unwrap()); + vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.002+00:00").unwrap()); + vec.push(DateTime::parse_from_rfc3339("2022-04-15T04:25:00.006+00:00").unwrap()); + let mut th = TimeHistogram::new(4, &vec); + th.load(&vec); + println!("{}", th); + println!("{:#?}", th); + let display = format!("{}", th); + assert!(display.contains("Matches: 3")); + assert!(display.contains("represents a count of 1")); + assert!(display.contains("[04:25:00.001000] [2] ∎∎\n")); + assert!(display.contains("[04:25:00.002250] [0] \n")); + assert!(display.contains("[04:25:00.003500] [0] \n")); + assert!(display.contains("[04:25:00.004750] [1] ∎\n")); + } +}